1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/feature/df-claim-tenant' into dev

This commit is contained in:
2026-06-15 20:31:02 +09:00
17 changed files with 693 additions and 197 deletions

View File

@@ -146,6 +146,8 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
# Refresh Token 만료시각 source of truth (Hydra + backend ID Token rt_expires_at claim)
HYDRA_REFRESH_TOKEN_TTL=720h
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
@@ -183,4 +185,3 @@ VITE_ORGCHART_URL=
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
LOKI_URL=http://loki:3100/loki/api/v1/push

View File

@@ -124,6 +124,7 @@ jobs:
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
"HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}" \
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
@@ -135,7 +136,7 @@ jobs:
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY HYDRA_REFRESH_TOKEN_TTL
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
"
for key in ${required_dotenv_keys}; do

View File

@@ -115,6 +115,7 @@ jobs:
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}

View File

@@ -123,6 +123,7 @@ jobs:
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
@@ -152,7 +153,7 @@ jobs:
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL JWKS_URL
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL HYDRA_REFRESH_TOKEN_TTL JWKS_URL
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS

View File

@@ -86,6 +86,7 @@ const (
linkResendCooldown = 60 * time.Second
prefixDrySend = "dry_send:"
headlessJWKSFetchTTL = 5 * time.Second
defaultRefreshTokenTTL = 30 * 24 * time.Hour
)
type AuthHandler struct {
@@ -1241,9 +1242,31 @@ func withOidcSessionMetadata(claims map[string]any, sessionID string) map[string
return claims
}
func hydraRefreshTokenTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("HYDRA_REFRESH_TOKEN_TTL"))
if raw == "" {
return defaultRefreshTokenTTL
}
ttl, err := time.ParseDuration(raw)
if err != nil || ttl <= 0 {
slog.Warn("invalid HYDRA_REFRESH_TOKEN_TTL, falling back to default", "value", raw, "default", defaultRefreshTokenTTL.String(), "error", err)
return defaultRefreshTokenTTL
}
return ttl
}
func withRefreshTokenExpiryClaim(claims map[string]any, issuedAt time.Time) map[string]any {
if claims == nil {
claims = map[string]any{}
}
claims["rt_expires_at"] = issuedAt.Add(hydraRefreshTokenTTL()).Unix()
return claims
}
func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, scopes []string, tenantID string, sessionID string) map[string]any {
claims := buildOidcClaimsFromTraits(traits, scopes, tenantID)
claims = applyConfiguredIDTokenClaims(claims, client.Metadata)
claims = withRefreshTokenExpiryClaim(claims, time.Now())
return withOidcSessionMetadata(claims, sessionID)
}

View File

@@ -11,12 +11,63 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func assertRefreshTokenExpiryClaimWithin(t *testing.T, claims map[string]any, issuedAfter, issuedBefore time.Time, ttl time.Duration) {
t.Helper()
rawExpiresAt, ok := claims["rt_expires_at"]
if !assert.True(t, ok, "rt_expires_at claim should exist") {
return
}
expiresAtFloat, ok := rawExpiresAt.(float64)
if !assert.True(t, ok, "rt_expires_at should be encoded as a unix timestamp number") {
return
}
expiresAt := time.Unix(int64(expiresAtFloat), 0)
assert.False(t, expiresAt.Before(issuedAfter.Add(ttl).Add(-time.Second)), "rt_expires_at should be after or equal to request start + ttl with second precision tolerance")
assert.False(t, expiresAt.After(issuedBefore.Add(ttl).Add(time.Second)), "rt_expires_at should be before or equal to request end + ttl")
}
func TestHydraRefreshTokenTTL_DefaultAndFallback(t *testing.T) {
t.Run("uses explicit env value", func(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "96h")
assert.Equal(t, 96*time.Hour, hydraRefreshTokenTTL())
})
t.Run("uses default when env is empty", func(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "")
assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL())
})
t.Run("uses default when env is invalid", func(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "not-a-duration")
assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL())
})
t.Run("uses default when env is non-positive", func(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "0h")
assert.Equal(t, defaultRefreshTokenTTL, hydraRefreshTokenTTL())
})
}
func TestWithRefreshTokenExpiryClaim_UsesHydraRefreshTokenTTL(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "36h")
issuedAt := time.Date(2026, time.June, 15, 14, 0, 0, 0, time.UTC)
claims := withRefreshTokenExpiryClaim(map[string]any{"email": "user@test.com"}, issuedAt)
assert.Equal(t, "user@test.com", claims["email"])
assert.Equal(t, issuedAt.Add(36*time.Hour).Unix(), claims["rt_expires_at"])
}
func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) {
traits := map[string]any{
"email": "user@baron.com",
@@ -131,6 +182,7 @@ func TestRepresentativeTenantIDFromTraits(t *testing.T) {
}
func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "48h")
var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -213,7 +265,9 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
issuedAfter := time.Now()
resp, err := app.Test(req)
issuedBefore := time.Now()
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -223,6 +277,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) {
assert.Equal(t, "tenant-abc", capturedClaims["tenant_id"])
assert.Equal(t, "Innovation", capturedClaims["department"])
assert.Equal(t, "Architect", capturedClaims["position"])
assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 48*time.Hour)
}
func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) {
@@ -603,6 +658,7 @@ func TestAcceptConsentRequest_DoesNotEmitLegacyProfileArray(t *testing.T) {
}
func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "24h")
var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
@@ -678,7 +734,9 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip-dynamic", nil)
issuedAfter := time.Now()
resp, err := app.Test(req)
issuedBefore := time.Now()
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
@@ -688,6 +746,87 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) {
assert.Equal(t, "tenant-xyz", capturedClaims["tenant_id"])
assert.Equal(t, "Security", capturedClaims["department"])
assert.Equal(t, "Officer", capturedClaims["position"])
assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 24*time.Hour)
}
func TestGetConsentRequest_LocalConsentAutoApprove_IncludesRefreshTokenExpiryClaim(t *testing.T) {
t.Setenv("HYDRA_REFRESH_TOKEN_TTL", "72h")
var capturedClaims map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-local-auto-approve",
"requested_scope": []string{"openid", "profile"},
"skip": false,
"subject": "user-local-123",
"client": map[string]any{
"client_id": "local-app",
"metadata": map[string]any{
"tenant_id": "tenant-local",
},
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-local-auto-approve" {
body, _ := io.ReadAll(r.Body)
var acceptReq map[string]any
json.Unmarshal(body, &acceptReq)
if session, ok := acceptReq["session"].(map[string]any); ok {
capturedClaims = session["id_token"].(map[string]any)
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() { http.DefaultClient = origDefault }()
consentRepo := &mockConsentRepo{
consents: []domain.ClientConsent{
{
ClientID: "local-app",
Subject: "user-local-123",
GrantedScopes: []string{"openid", "profile"},
},
},
}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
ConsentRepo: consentRepo,
}
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-local-123").Return(&service.KratosIdentity{
ID: "user-local-123",
Traits: map[string]any{
"email": "local@test.com",
"name": "Local User",
},
}, nil)
app := fiber.New()
app.Get("/api/v1/auth/consent", h.GetConsentRequest)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-local-auto-approve", nil)
issuedAfter := time.Now()
resp, err := app.Test(req)
issuedBefore := time.Now()
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims)
assert.Equal(t, "local@test.com", capturedClaims["email"])
assertRefreshTokenExpiryClaimWithin(t, capturedClaims, issuedAfter, issuedBefore, 72*time.Hour)
}
func TestBuildOidcClaimsFromTraits_IncludesGlobalCustomClaims(t *testing.T) {

View File

@@ -10,7 +10,7 @@ const navigateMock = vi.fn();
const fetchClientMock = vi.fn();
const updateClientMock = vi.fn();
const fetchClientRelationsMock = vi.fn();
const fetchMyTenantsMock = vi.fn();
const fetchTenantsMock = vi.fn();
const fetchMeMock = vi.fn();
let authState = {
@@ -45,7 +45,7 @@ vi.mock("../../lib/devApi", () => ({
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
fetchClientRelations: (...args: unknown[]) =>
fetchClientRelationsMock(...args),
fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args),
fetchTenants: (...args: unknown[]) => fetchTenantsMock(...args),
refreshHeadlessJwksCache: vi.fn(),
revokeHeadlessJwksCache: vi.fn(),
updateClient: (...args: unknown[]) => updateClientMock(...args),
@@ -217,7 +217,12 @@ describe("ClientGeneralPage RP claims", () => {
fetchClientMock.mockResolvedValue(makeClientDetail("old_claim"));
updateClientMock.mockResolvedValue(makeClientDetail("new_claim"));
fetchClientRelationsMock.mockResolvedValue({ items: [] });
fetchMyTenantsMock.mockResolvedValue([]);
fetchTenantsMock.mockResolvedValue({
items: [],
limit: 1000,
offset: 0,
total: 0,
});
fetchMeMock.mockResolvedValue({
id: "admin-user",
role: "super_admin",

View File

@@ -5,7 +5,6 @@ import {
Info,
Plus,
Save,
Search,
Shield,
ShieldHalf,
Sparkles,
@@ -27,16 +26,24 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { CopyButton } from "../../components/ui/copy-button";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import type {
ClientStatus,
ClientType,
ClientUpsertRequest,
MyTenantSummary,
TenantSummary,
} from "../../lib/devApi";
import {
@@ -45,7 +52,7 @@ import {
deleteClient,
fetchClient,
fetchClientRelations,
fetchMyTenants,
fetchTenants,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
updateClient,
@@ -57,7 +64,7 @@ import { cn } from "../../lib/utils";
import { fetchMe, type UserProfile } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
import { ClientDetailTabs } from "./ClientDetailTabs";
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
import { TenantAccessPicker } from "./components/TenantAccessPicker";
import {
claimDateTimeValueToInputString,
dateTimeInputToUnixSeconds,
@@ -597,8 +604,8 @@ function ClientGeneralPage() {
retry: false,
});
const { data: tenantData } = useQuery({
queryKey: ["my-tenants"],
queryFn: fetchMyTenants,
queryKey: ["tenants", "all"],
queryFn: () => fetchTenants(),
});
const [name, setName] = useState("");
@@ -622,8 +629,6 @@ function ClientGeneralPage() {
] = useState(false);
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
const [tenantSearch, setTenantSearch] = useState("");
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
const [autoLoginUrl, setAutoLoginUrl] = useState("");
@@ -990,10 +995,6 @@ function ClientGeneralPage() {
const handleTenantAccessToggle = (enabled: boolean) => {
setTenantAccessRestricted(enabled);
setIsTenantSearchOpen(enabled);
if (!enabled) {
setTenantSearch("");
}
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
};
@@ -1009,8 +1010,6 @@ function ClientGeneralPage() {
setAllowedTenantIds((current) =>
current.includes(tenantId) ? current : [...current, tenantId],
);
setTenantSearch("");
setIsTenantSearchOpen(true);
};
const addScope = () => {
@@ -1270,25 +1269,10 @@ function ClientGeneralPage() {
normalizedIdTokenClaimItems,
);
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
tenantData ?? [];
const filteredTenants = tenantOptions.filter((tenant) => {
if (!normalizedTenantSearch) {
return true;
}
const searchable =
`${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase();
return searchable.includes(normalizedTenantSearch);
});
const tenantSuggestions = filteredTenants
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
.slice(0, 8);
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
const selectedAllowedTenants = allowedTenantIds
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
.filter(
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
);
.filter((tenant): tenant is TenantSummary => tenant != null);
const refreshHeadlessJwksCacheMutation = useMutation({
mutationFn: async () => {
@@ -2322,12 +2306,20 @@ function ClientGeneralPage() {
"테넌트 접근 제한",
)}
</CardTitle>
<CardDescription>
{t(
"ui.dev.clients.general.tenant_access.subtitle",
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
)}
</CardDescription>
<div className="text-sm text-muted-foreground">
<p className="leading-relaxed">
{t(
"ui.dev.clients.general.tenant_access.subtitle",
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
)}
</p>
<p className="leading-relaxed">
{t(
"ui.dev.clients.general.tenant_access.hint",
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="space-y-0.5 text-right">
@@ -2358,154 +2350,168 @@ function ClientGeneralPage() {
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.hint",
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
)}
</p>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-3">
<Label htmlFor="tenant-search" className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
</Label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="tenant-search"
value={tenantSearch}
onFocus={() => {
if (tenantAccessRestricted) {
setIsTenantSearchOpen(true);
}
}}
onBlur={() => {
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
}}
onChange={(e) => {
setTenantSearch(e.target.value);
if (tenantAccessRestricted) {
setIsTenantSearchOpen(true);
}
}}
placeholder={t(
"ui.dev.clients.general.tenant_access.search_placeholder",
"테넌트 이름 또는 슬러그로 검색",
)}
className="pl-10"
disabled={
isGeneralSettingsReadOnly || !tenantAccessRestricted
<CardContent className="space-y-3">
{tenantAccessRestricted ? (
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.picker_label",
"허용 테넌트 추가",
)}{" "}
<span className="text-destructive">*</span>
</Label>
<TenantAccessPicker
disabled={isGeneralSettingsReadOnly}
selectedCount={allowedTenantIds.length}
onSelectTenant={(selection) =>
handleSelectAllowedTenant(selection.id)
}
/>
{tenantAccessRestricted && isTenantSearchOpen && (
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
{tenantSuggestions.length > 0 ? (
tenantSuggestions.map((tenant) => (
<button
key={tenant.id}
type="button"
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
onMouseDown={(event) => {
event.preventDefault();
handleSelectAllowedTenant(tenant.id);
}}
disabled={isGeneralSettingsReadOnly}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{tenant.name}
</span>
<Badge variant="outline" className="text-[11px]">
{tenant.slug}
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
{tenant.description || tenant.type}
</p>
</div>
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
))
) : (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.empty",
"검색 결과가 없습니다.",
)}
</div>
)}
</div>
)}
</div>
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.autocomplete_hint",
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.selected_title",
"허용 테넌트",
)}
</Label>
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
<div className="flex flex-wrap gap-2">
{selectedAllowedTenants.map((tenant) => (
<AllowedTenantBadge
key={tenant.id}
tenant={tenant}
onRemove={() => toggleAllowedTenant(tenant.id)}
disabled={isGeneralSettingsReadOnly}
/>
))}
{allowedTenantIds
.filter(
(tenantId) =>
!selectedAllowedTenants.some(
(tenant) => tenant.id === tenantId,
),
)
.map((tenantId) => (
<AllowedTenantBadge
key={tenantId}
tenant={{ id: tenantId, name: tenantId }}
onRemove={() => toggleAllowedTenant(tenantId)}
disabled={isGeneralSettingsReadOnly}
/>
))}
</div>
) : (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
{tenantAccessRestricted
? t(
"ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.",
)
: t(
"ui.dev.clients.general.tenant_access.disabled",
"제한 없음",
)}
</div>
)}
<div className="space-y-3">
<Label className="text-sm font-semibold">
{t(
"ui.dev.clients.general.tenant_access.selected_title",
"허용 테넌트",
)}
</Label>
<div className="overflow-hidden rounded-md border border-border bg-background">
{allowedTenantIds.length > 0 ? (
<div className="max-h-80 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50">
<TableRow>
<TableHead className="w-[34%] px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.tenant_access.table.name",
"테넌트명",
)}
</TableHead>
<TableHead className="w-[18%] px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.tenant_access.table.slug",
"슬러그",
)}
</TableHead>
<TableHead className="px-4 py-3 text-left font-bold">
{t(
"ui.dev.clients.general.tenant_access.table.id",
"테넌트 ID",
)}
</TableHead>
<TableHead className="w-[112px] px-4 py-3 text-right font-bold">
{t(
"ui.dev.clients.general.tenant_access.table.actions",
"작업",
)}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedAllowedTenants.map((tenant) => (
<TableRow
key={tenant.id}
data-testid={`allowed-tenant-${tenant.id}`}
>
<TableCell className="px-4 py-3 font-medium">
{tenant.name}
</TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">
{tenant.slug || "-"}
</TableCell>
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground">
<span className="break-all">{tenant.id}</span>
</TableCell>
<TableCell className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1.5">
<CopyButton
aria-label="테넌트 UUID 복사"
className="h-8 w-8"
data-testid={`allowed-tenant-copy-${tenant.id}`}
size="icon"
value={tenant.id}
variant="ghost"
/>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() =>
toggleAllowedTenant(tenant.id)
}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
data-testid={`allowed-tenant-remove-${tenant.id}`}
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
{allowedTenantIds
.filter(
(tenantId) =>
!selectedAllowedTenants.some(
(tenant) => tenant.id === tenantId,
),
)
.map((tenantId) => (
<TableRow
key={tenantId}
data-testid={`allowed-tenant-${tenantId}`}
>
<TableCell className="px-4 py-3 font-medium">
{tenantId}
</TableCell>
<TableCell className="px-4 py-3 text-muted-foreground">
-
</TableCell>
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground">
<span className="break-all">{tenantId}</span>
</TableCell>
<TableCell className="px-4 py-3 text-right">
<div className="inline-flex items-center gap-1.5">
<CopyButton
aria-label="테넌트 UUID 복사"
className="h-8 w-8"
data-testid={`allowed-tenant-copy-${tenantId}`}
size="icon"
value={tenantId}
variant="ghost"
/>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={() =>
toggleAllowedTenant(tenantId)
}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
data-testid={`allowed-tenant-remove-${tenantId}`}
disabled={isGeneralSettingsReadOnly}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex min-h-[99px] items-center px-4 py-3 text-sm text-muted-foreground">
{t(
"ui.dev.clients.general.tenant_access.selected_empty",
"아직 선택된 테넌트가 없습니다.",
)}
</div>
)}
</div>
</div>
</div>
</div>
) : null}
</CardContent>
</Card>

View File

@@ -0,0 +1,146 @@
import { Building2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "../../../components/ui/button";
import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "../orgChartPicker";
type TenantAccessPickerProps = {
disabled?: boolean;
selectedCount: number;
onSelectTenant: (selection: OrgChartTenantSelection) => void;
};
function resolveOrgFrontBaseUrl() {
return (
import.meta.env.VITE_ORGFRONT_PUBLIC_URL ||
import.meta.env.ORGFRONT_URL ||
"http://localhost:5175"
);
}
export function TenantAccessPicker({
disabled = false,
selectedCount,
onSelectTenant,
}: TenantAccessPickerProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const pickerUrl = useMemo(
() => buildAuthenticatedOrgChartTenantPickerUrl(resolveOrgFrontBaseUrl()),
[],
);
useEffect(() => {
if (!pickerOpen) return;
const onMessage = (event: MessageEvent) => {
const selection = parseOrgChartTenantSelection(event.data);
if (!selection) return;
onSelectTenant(selection);
setPickerOpen(false);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [onSelectTenant, pickerOpen]);
const pickerDialog =
pickerOpen && typeof document !== "undefined"
? createPortal(
<div
className="fixed inset-0 z-[1000] flex items-start justify-center overflow-y-auto bg-black/50 p-4 md:items-center"
role="dialog"
aria-modal="true"
aria-label={t(
"ui.dev.clients.general.tenant_access.picker_title",
"테넌트 선택",
)}
>
<div className="flex h-[92vh] w-[min(96vw,1200px)] flex-col overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-2xl">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t(
"ui.dev.clients.general.tenant_access.picker_title",
"테넌트 선택",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.general.tenant_access.picker_description",
"orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.",
)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => setPickerOpen(false)}
>
{t("ui.common.close", "닫기")}
</Button>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-md border">
<iframe
title={t(
"ui.dev.clients.general.tenant_access.picker_title",
"테넌트 선택",
)}
src={pickerUrl}
className="h-full w-full"
/>
</div>
<div className="mt-4 flex shrink-0 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setPickerOpen(false)}
>
{t("ui.common.close", "닫기")}
</Button>
</div>
</div>
</div>,
document.body,
)
: null;
return (
<div className="space-y-3">
<Button
type="button"
variant="outline"
className="h-10 gap-2"
disabled={disabled}
onClick={() => setPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{t(
"ui.dev.clients.general.tenant_access.open_picker",
"테넌트 선택기 열기",
)}
</Button>
{pickerDialog}
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
{selectedCount > 0
? t(
"msg.dev.clients.general.tenant_access.picker_hint_with_count",
"현재 {{count}}개가 선택되어 있습니다.",
{ count: selectedCount },
)
: t(
"msg.dev.clients.general.tenant_access.picker_hint",
"선택기를 열어 허용 테넌트를 추가하세요.",
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
export type OrgChartTenantSelection = {
id: string;
name: string;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
selections?: Array<{
type?: unknown;
id?: unknown;
name?: unknown;
}>;
};
};
type OrgChartTenantPickerOptions = {
includeInternal?: boolean;
tenantId?: string;
};
function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: { includeInternal?: boolean; returnTo?: string } = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
let returnTo = options.returnTo?.trim() || "/chart";
if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2);
const params = new URLSearchParams(query);
params.set("includeInternal", "true");
returnTo = `${path}?${params.toString()}`;
}
const params = new URLSearchParams({
auto: "1",
returnTo,
});
return `${normalizedBase}/login?${params.toString()}`;
}
export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").trim() || "http://localhost:5175";
const params = new URLSearchParams({
mode: "single",
select: "tenant",
width: "400",
height: "600",
});
const tenantId = options.tenantId?.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
if (options.includeInternal) {
params.set("includeInternal", "true");
}
const pickerUrl = `/embed/picker?${params.toString()}`;
return buildAuthenticatedOrgChartUrl(normalizedBase, {
includeInternal: true,
returnTo: pickerUrl,
});
}
export function parseOrgChartTenantSelection(
message: unknown,
): OrgChartTenantSelection | null {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return null;
}
const selection = data.payload?.selections?.[0];
if (
selection?.type !== "tenant" ||
typeof selection.id !== "string" ||
typeof selection.name !== "string" ||
selection.id.trim() === ""
) {
return null;
}
return {
id: selection.id,
name: selection.name,
};
}

View File

@@ -65,8 +65,14 @@ describe("devApi", () => {
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
params: { days: 30, period: "week" },
});
expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
params: { limit: 25, offset: 50, parentId: "tenant-parent" },
expect(apiClient.get).toHaveBeenCalledWith("/admin/tenants", {
params: {
limit: 25,
offset: 50,
parentId: "tenant-parent",
cursor: undefined,
search: undefined,
},
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
expect(apiClient.get).toHaveBeenCalledWith(

View File

@@ -283,9 +283,11 @@ export async function fetchTenants(
limit = 1000,
offset = 0,
parentId?: string,
cursor?: string,
search?: string,
) {
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
params: { limit, offset, parentId },
const { data } = await apiClient.get<TenantListResponse>("/admin/tenants", {
params: { limit, offset, parentId, cursor, search },
});
return data;
}

View File

@@ -65,6 +65,15 @@ test.describe("DevFront client tenant access settings", () => {
],
consents: [] as Consent[],
auditLogsByCursor: undefined,
myTenants: [
{
id: existingTenantId,
name: "Alpha Tenant",
slug: "alpha",
description: "Existing allowed tenant",
type: "organization",
},
],
tenants: [
{
id: existingTenantId,
@@ -99,10 +108,27 @@ test.describe("DevFront client tenant access settings", () => {
)
.toBe(existingTenantId);
await page
.getByPlaceholder(/테넌트 이름 또는 슬러그로 검색|tenant name or slug/i)
.fill("beta");
await page.getByRole("button", { name: /Beta Tenant/i }).click();
await page.getByRole("button", { name: /테넌트 선택기 열기/i }).click();
await page.evaluate(
(selection) => {
window.postMessage(
{
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: selection.id,
name: selection.name,
},
],
},
},
"*",
);
},
{ id: addedTenantId, name: "Beta Tenant" },
);
await expect(
page.getByTestId(`allowed-tenant-${addedTenantId}`),
).toContainText(addedTenantId);

View File

@@ -132,6 +132,7 @@ export type DevApiMockState = {
relations?: Record<string, ClientRelation[]>;
users?: DevAssignableUser[];
tenants?: DevTenantSummary[];
myTenants?: DevTenantSummary[];
auditLogsByCursor?: Record<
string,
{ items: AuditLog[]; next_cursor?: string }
@@ -437,6 +438,33 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
});
await page.route("**/api/v1/admin/tenants**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const { searchParams } = url;
const tenants = state.tenants ?? [
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
];
return json(route, {
items: tenants.map((tenant) => ({
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
description: tenant.description ?? "",
type: tenant.type ?? "organization",
parentId: null,
status: "active",
memberCount: 0,
createdAt: "2026-03-03T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
})),
limit: Number.parseInt(searchParams.get("limit") || "1000", 10),
offset: Number.parseInt(searchParams.get("offset") || "0", 10),
total: tenants.length,
});
});
await page.route("**/api/v1/dev/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
@@ -534,9 +562,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
return json(
route,
state.tenants ?? [
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
],
state.myTenants ??
state.tenants ?? [
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
],
);
}

View File

@@ -96,3 +96,4 @@ oidc:
ttl:
access_token: 15m
id_token: 15m
refresh_token: ${HYDRA_REFRESH_TOKEN_TTL}

View File

@@ -183,10 +183,11 @@ KRATOS_DSN="${KRATOS_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWOR
HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}"
KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}"
HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}"
HYDRA_REFRESH_TOKEN_TTL="${HYDRA_REFRESH_TOKEN_TTL:-720h}"
OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}"
OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}"
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET HYDRA_REFRESH_TOKEN_TTL
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
resolve_kratos_session_cookie_domain

View File

@@ -306,6 +306,17 @@ if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging
exit 1
fi
for workflow_file in \
"$repo_root/.gitea/workflows/staging_code_pull.yml" \
"$repo_root/.gitea/workflows/staging_release.yml" \
"$repo_root/.gitea/workflows/production_release.yml"
do
if ! grep -q 'HYDRA_REFRESH_TOKEN_TTL' "$workflow_file"; then
echo "ERROR: workflow must propagate HYDRA_REFRESH_TOKEN_TTL into deployment env: $workflow_file" >&2
exit 1
fi
done
if ! grep -q 'up -d --force-recreate kratos hydra keto oathkeeper' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then
echo "ERROR: staging code pull must restart Ory services after rendering static config." >&2
exit 1
@@ -334,11 +345,13 @@ KRATOS_UI_URL=https://sso.hmac.kr
KRATOS_BROWSER_URL=https://sso.hmac.kr/auth
KRATOS_ADMIN_URL=http://kratos:4434
ORY_POSTGRES_PASSWORD=policy-test
HYDRA_REFRESH_TOKEN_TTL=168h
KRATOS_ALLOWED_RETURN_URLS_JSON=
KRATOS_ALLOWED_RETURN_URLS_EXTRA=
EOF
ORY_CONFIG_ENV_FILES="$stage_render_env" ORY_CONFIG_OUTPUT_DIR="$stage_render_dir/ory" "$repo_root/scripts/render_ory_config.sh" >/dev/null
stage_rendered_kratos="$stage_render_dir/ory/kratos/kratos.yml"
stage_rendered_hydra="$stage_render_dir/ory/hydra/hydra.yml"
if ! awk '/allowed_return_urls:/ { in_block=1; next } in_block && /^[[:space:]]+methods:/ { exit } in_block { print }' "$stage_rendered_kratos" | grep -q 'https://sso.hmac.kr'; then
echo "ERROR: rendered stage Kratos config must include the public userfront URL in allowed_return_urls." >&2
exit 1
@@ -351,6 +364,10 @@ if ! awk '/session:/ { in_session=1 } in_session && /domain:/ { print; exit }' "
echo "ERROR: rendered stage Kratos config must derive hmac.kr as session.cookie.domain." >&2
exit 1
fi
if ! awk '/ttl:/ { in_ttl=1; next } in_ttl && /^[^[:space:]]/ { exit } in_ttl { print }' "$stage_rendered_hydra" | grep -q 'refresh_token: 168h'; then
echo "ERROR: rendered stage Hydra config must include HYDRA_REFRESH_TOKEN_TTL as ttl.refresh_token." >&2
exit 1
fi
rm -rf "$stage_render_dir" "$stage_render_env"
for generated_config in \