1
0
forked from baron/baron-sso

custom claim 타입보정 UI. 대표테넌트 노출 보정

This commit is contained in:
2026-06-11 11:27:11 +09:00
parent 0bb3ccb850
commit f60b15a17b
37 changed files with 2952 additions and 417 deletions

View File

@@ -132,6 +132,7 @@ jobs:
global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)' global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)'
front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)' front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)'
i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)'
react_i18n='^(adminfront/src/locales/|devfront/src/locales/|orgfront/src/locales/)'
backend=false backend=false
userfront=false userfront=false
@@ -154,7 +155,7 @@ jobs:
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi
lint=false lint=false
if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then if [ "$backend" = true ] || [ "$userfront" = true ] || matches "$global|$i18n_shared|$react_i18n"; then
lint=true lint=true
fi fi
@@ -213,42 +214,6 @@ jobs:
channel: "stable" channel: "stable"
cache: true cache: true
- name: Install adminfront dependencies
run: |
cd adminfront
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check adminfront (lint + format)
run: |
cd adminfront
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Install devfront dependencies
run: |
cd devfront
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check devfront (lint + format)
run: |
cd devfront
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Install orgfront dependencies
run: |
cd orgfront
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check orgfront (lint + format)
run: |
cd orgfront
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Lint Go backend - name: Lint Go backend
run: | run: |
docker run --rm \ docker run --rm \
@@ -879,7 +844,7 @@ jobs:
adminfront-vitest-coverage: adminfront-vitest-coverage:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1010,7 +975,7 @@ jobs:
devfront-vitest-coverage: devfront-vitest-coverage:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1141,7 +1106,7 @@ jobs:
orgfront-vitest-coverage: orgfront-vitest-coverage:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1272,7 +1237,7 @@ jobs:
adminfront-tests: adminfront-tests:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
@@ -1367,7 +1332,7 @@ jobs:
devfront-tests: devfront-tests:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1550,7 +1515,7 @@ jobs:
orgfront-tests: orgfront-tests:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }} if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -18,6 +18,11 @@ const notify = () => {
}; };
const toastBase = (message: string, type: ToastType = "success") => { const toastBase = (message: string, type: ToastType = "success") => {
if (
toasts.some((toast) => toast.message === message && toast.type === type)
) {
return;
}
const id = Math.random().toString(36).substring(2, 9); const id = Math.random().toString(36).substring(2, 9);
toasts = [...toasts, { id, message, type }]; toasts = [...toasts, { id, message, type }];
notify(); notify();

View File

@@ -656,7 +656,7 @@ export function TenantWorksmobilePage() {
actionDisabled={isCreatingUsers || createSelectedMutation.isPending} actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
updateActionLabel="선택 구성원 업데이트 적용" updateActionLabel="선택 구성원 업데이트 적용"
onCreateSelected={(ids, initialPassword) => onCreateSelected={(ids, initialPassword) =>
createSelectedMutation.mutate({ createSelectedMutation.mutateAsync({
resourceKind: "users", resourceKind: "users",
ids, ids,
initialPassword, initialPassword,
@@ -1031,7 +1031,7 @@ function ComparisonTable({
actionLabel: string; actionLabel: string;
updateActionLabel?: string; updateActionLabel?: string;
actionDisabled: boolean; actionDisabled: boolean;
onCreateSelected: (ids: string[], initialPassword?: string) => void; onCreateSelected: (ids: string[], initialPassword?: string) => unknown;
onUpdateSelected?: (ids: string[]) => void; onUpdateSelected?: (ids: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void; onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string; deleteActionLabel?: string;
@@ -1222,13 +1222,17 @@ function ComparisonTable({
onUpdateSelected(selectedUpdateUserIds); onUpdateSelected(selectedUpdateUserIds);
}; };
const confirmInitialPassword = () => { const confirmInitialPassword = async () => {
const password = initialPassword.trim(); const password = initialPassword.trim();
if (!password) { if (!password) {
toast.error("WORKS 초기 비밀번호를 입력해 주세요."); toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
return; return;
} }
onCreateSelected(pendingInitialPasswordIds, password); try {
await onCreateSelected(pendingInitialPasswordIds, password);
} catch {
return;
}
setInitialPasswordOpen(false); setInitialPasswordOpen(false);
setInitialPassword(""); setInitialPassword("");
setPendingInitialPasswordIds([]); setPendingInitialPasswordIds([]);
@@ -1383,7 +1387,11 @@ function ComparisonTable({
> >
</Button> </Button>
<Button type="button" onClick={confirmInitialPassword}> <Button
type="button"
onClick={confirmInitialPassword}
disabled={actionDisabled}
>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -1,12 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants"; import { getSeedTenantIds, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => { describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => { it("marks tenants from seed-tenant.csv as protected by UUID", () => {
expect(getSeedTenantSlugs()).toEqual( expect(getSeedTenantIds()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]), expect.arrayContaining([
"038326b6-954a-48a7-a85f-efd83f62b82a",
"5a03efd2-e62f-4243-800d-58334bf48b2f",
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
]),
); );
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true); expect(
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false); isSeedTenant({
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
}),
).toBe(true);
expect(
isSeedTenant({
id: "5A03EFD2-E62F-4243-800D-58334BF48B2F",
}),
).toBe(true);
expect(isSeedTenant({ id: "normal-tenant" })).toBe(false);
}); });
}); });

View File

@@ -4,16 +4,15 @@ import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
import type { TenantSummary } from "../../../lib/adminApi"; import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport"; import { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set( const seedTenants = parseTenantCSV(seedTenantCSVRaw);
parseTenantCSV(seedTenantCSVRaw) const seedTenantIds = new Set(
.map((row) => row.slug.trim().toLowerCase()) seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean),
.filter(Boolean),
); );
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean { export function isSeedTenant(tenant: Pick<TenantSummary, "id">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase()); return seedTenantIds.has(tenant.id.trim().toLowerCase());
} }
export function getSeedTenantSlugs(): string[] { export function getSeedTenantIds(): string[] {
return Array.from(seedTenantSlugs); return Array.from(seedTenantIds);
} }

View File

@@ -0,0 +1,96 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import GlobalCustomClaimsPage from "./GlobalCustomClaimsPage";
const fetchGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
const updateGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchGlobalCustomClaimDefinitions: fetchGlobalCustomClaimDefinitionsMock,
updateGlobalCustomClaimDefinitions: updateGlobalCustomClaimDefinitionsMock,
}));
vi.mock("../../components/ui/use-toast", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderGlobalCustomClaimsPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<GlobalCustomClaimsPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("GlobalCustomClaimsPage", () => {
beforeEach(() => {
fetchGlobalCustomClaimDefinitionsMock.mockReset();
fetchGlobalCustomClaimDefinitionsMock.mockResolvedValue({
items: [
{
key: "locale",
label: "Locale",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
],
});
updateGlobalCustomClaimDefinitionsMock.mockReset();
updateGlobalCustomClaimDefinitionsMock.mockResolvedValue({ items: [] });
});
it("forces user read permission on when user write permission is enabled", async () => {
renderGlobalCustomClaimsPage();
const readSelect = await screen.findByTestId(
"global-claim-definition-read-permission-locale",
);
const writeSelect = await screen.findByTestId(
"global-claim-definition-write-permission-locale",
);
expect(readSelect).toHaveValue("admin_only");
expect(writeSelect).toHaveValue("admin_only");
fireEvent.change(writeSelect, { target: { value: "user_and_admin" } });
await waitFor(() => {
expect(readSelect).toHaveValue("user_and_admin");
expect(writeSelect).toHaveValue("user_and_admin");
});
fireEvent.click(screen.getByRole("button", { name: /저장|Save/ }));
await waitFor(() => {
expect(updateGlobalCustomClaimDefinitionsMock).toHaveBeenCalled();
});
expect(updateGlobalCustomClaimDefinitionsMock.mock.calls[0][0]).toEqual({
items: [
expect.objectContaining({
key: "locale",
readPermission: "user_and_admin",
writePermission: "user_and_admin",
}),
],
});
});
});

View File

@@ -52,6 +52,7 @@ function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] { function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
return drafts return drafts
.map((draft) => normalizeClaimDraftPermissions(draft))
.map((draft) => ({ .map((draft) => ({
key: draft.key.trim(), key: draft.key.trim(),
label: draft.label.trim(), label: draft.label.trim(),
@@ -63,6 +64,16 @@ function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
.filter((draft) => draft.key.length > 0); .filter((draft) => draft.key.length > 0);
} }
function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft {
if (draft.writePermission !== "user_and_admin") {
return draft;
}
return {
...draft,
readPermission: "user_and_admin",
};
}
function permissionLabel(permission: GlobalCustomClaimPermission) { function permissionLabel(permission: GlobalCustomClaimPermission) {
return permission === "user_and_admin" return permission === "user_and_admin"
? t( ? t(
@@ -116,7 +127,9 @@ export default function GlobalCustomClaimsPage() {
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => { const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
setDrafts((current) => setDrafts((current) =>
current.map((draft) => current.map((draft) =>
draft.id === id ? { ...draft, ...patch } : draft, draft.id === id
? normalizeClaimDraftPermissions({ ...draft, ...patch })
: draft,
), ),
); );
}; };
@@ -140,7 +153,7 @@ export default function GlobalCustomClaimsPage() {
)} )}
description={t( description={t(
"msg.admin.users.global_custom_claims.description", "msg.admin.users.global_custom_claims.description",
"모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.", "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.",
)} )}
actions={ actions={
<> <>
@@ -185,7 +198,7 @@ export default function GlobalCustomClaimsPage() {
<CardDescription> <CardDescription>
{t( {t(
"msg.admin.users.global_custom_claims.registry", "msg.admin.users.global_custom_claims.registry",
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.", "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.",
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>

View File

@@ -2051,7 +2051,7 @@ function UserDetailPage() {
<CardDescription> <CardDescription>
{t( {t(
"msg.admin.users.detail.custom_claims.description", "msg.admin.users.detail.custom_claims.description",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.", "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
)} )}
</CardDescription> </CardDescription>
</div> </div>

View File

@@ -22,6 +22,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({
})); }));
const fetchUsersMock = vi.hoisted(() => vi.fn()); const fetchUsersMock = vi.hoisted(() => vi.fn());
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs = const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300; process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
@@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
name: "Admin", name: "Admin",
email: "admin@example.com", email: "admin@example.com",
})), })),
fetchAllTenants: vi.fn(async () => ({ fetchAllTenants: fetchAllTenantsMock,
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
})),
fetchTenant: vi.fn(async () => ({ fetchTenant: vi.fn(async () => ({
id: "tenant-1", id: "tenant-1",
name: "한맥", name: "한맥",
@@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => {
beforeEach(() => { beforeEach(() => {
selectRenderCounter.count = 0; selectRenderCounter.count = 0;
fetchUsersMock.mockReset(); fetchUsersMock.mockReset();
fetchAllTenantsMock.mockReset();
fetchAllTenantsMock.mockResolvedValue({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
});
fetchUsersMock.mockImplementation( fetchUsersMock.mockImplementation(
async (_limit: number, _offset: number, search?: string) => { async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase(); const normalizedSearch = search?.trim().toLowerCase();
@@ -157,7 +160,7 @@ describe("UserListPage search rendering", () => {
expect(content).toHaveClass("flex", "h-full", "items-center"); expect(content).toHaveClass("flex", "h-full", "items-center");
}); });
it("renders additional tenant appointments in the tenant column", async () => { it("does not render private additional tenant appointments in the tenant column", async () => {
fetchUsersMock.mockResolvedValueOnce({ fetchUsersMock.mockResolvedValueOnce({
items: [ items: [
{ {
@@ -183,7 +186,63 @@ describe("UserListPage search rendering", () => {
expect( expect(
await screen.findByText("Additional Tenant User"), await screen.findByText("Additional Tenant User"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("비공개 팀")).toBeInTheDocument(); expect(screen.getAllByText("한맥").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
});
it("excludes private tenants when choosing the representative tenant for the user list", async () => {
fetchAllTenantsMock.mockResolvedValueOnce({
items: [
{
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
total: 2,
});
fetchUsersMock.mockResolvedValueOnce({
items: [
{
...users[0],
name: "Private Primary User",
tenantSlug: "private-team",
tenant: {
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
joinedTenants: [
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
metadata: {
primaryTenantId: "tenant-private",
primaryTenantSlug: "private-team",
primaryTenantName: "비공개 팀",
},
},
],
total: 1,
});
renderUserListPage();
expect(await screen.findByText("Private Primary User")).toBeInTheDocument();
expect(screen.getByText("공개 팀")).toBeInTheDocument();
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
}); });
it("centers the initial loading message across the user table", async () => { it("centers the initial loading message across the user table", async () => {

View File

@@ -151,50 +151,111 @@ function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user"; return isSuperAdminRole(role) ? "super_admin" : "user";
} }
function collectAdditionalTenantLabels(user: UserSummary) { type RepresentativeTenantCandidate = {
const primaryKeys = new Set( id?: string;
[user.tenant?.id, user.tenant?.slug, user.tenantSlug] slug?: string;
.filter((value): value is string => Boolean(value)) name?: string;
.map((value) => value.toLowerCase()), config?: Record<string, unknown>;
};
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function tenantVisibility(tenant?: RepresentativeTenantCandidate) {
const visibility = tenant?.config?.visibility;
return typeof visibility === "string" ? visibility.trim() : "";
}
function findTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const id = candidate.id?.toLowerCase() ?? "";
const slug = candidate.slug?.toLowerCase() ?? "";
if (!id && !slug) return undefined;
return tenants.find(
(tenant) =>
(id && tenant.id.toLowerCase() === id) ||
(slug && tenant.slug.toLowerCase() === slug),
); );
const labels: string[] = []; }
const seen = new Set<string>();
const addLabel = ( function isPrivateTenantCandidate(
tenantId?: unknown, candidate: RepresentativeTenantCandidate,
tenantSlug?: unknown, tenants: TenantSummary[],
tenantName?: unknown, ) {
) => { const tenant = findTenantCandidate(candidate, tenants) ?? candidate;
const id = typeof tenantId === "string" ? tenantId.trim() : ""; return tenantVisibility(tenant) === "private";
const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : ""; }
const name = typeof tenantName === "string" ? tenantName.trim() : "";
const key = (id || slug || name).toLowerCase(); function candidateLabel(candidate: RepresentativeTenantCandidate) {
if (!key || primaryKeys.has(key) || seen.has(key)) { return candidate.name || candidate.slug || candidate.id || "";
return; }
}
seen.add(key); function metadataTenantCandidate(
labels.push(name || slug || id); metadata: Record<string, unknown> | undefined,
}; ): RepresentativeTenantCandidate | null {
const id = stringValue(metadata?.primaryTenantId);
const slug = stringValue(metadata?.primaryTenantSlug);
const name = stringValue(metadata?.primaryTenantName);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function appointmentTenantCandidate(
appointment: unknown,
): RepresentativeTenantCandidate | null {
if (!appointment || typeof appointment !== "object") return null;
const value = appointment as Record<string, unknown>;
const id = stringValue(value.tenantId);
const slug = stringValue(value.tenantSlug ?? value.slug);
const name = stringValue(value.tenantName ?? value.name);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function resolveRepresentativeTenantLabel(
user: UserSummary,
tenants: TenantSummary[],
) {
const candidates: RepresentativeTenantCandidate[] = [];
const knownTenants = [
...(user.tenant ? [user.tenant] : []),
...(user.joinedTenants ?? []),
...tenants,
];
const primaryFromMetadata = metadataTenantCandidate(user.metadata);
if (primaryFromMetadata) candidates.push(primaryFromMetadata);
if (user.tenant) candidates.push(user.tenant);
for (const tenant of user.joinedTenants ?? []) { for (const tenant of user.joinedTenants ?? []) {
addLabel(tenant.id, tenant.slug, tenant.name); candidates.push(tenant);
} }
const appointments = user.metadata?.additionalAppointments; const appointments = user.metadata?.additionalAppointments;
if (Array.isArray(appointments)) { if (Array.isArray(appointments)) {
for (const appointment of appointments) { for (const appointment of appointments) {
if (!appointment || typeof appointment !== "object") { if (
appointment &&
typeof appointment === "object" &&
(appointment as Record<string, unknown>).isPrimary !== true
) {
continue; continue;
} }
const value = appointment as Record<string, unknown>; const candidate = appointmentTenantCandidate(appointment);
addLabel( if (candidate) candidates.push(candidate);
value.tenantId,
value.tenantSlug ?? value.slug,
value.tenantName ?? value.name,
);
} }
} }
if (user.tenantSlug) candidates.push({ slug: user.tenantSlug });
return labels; const representative = candidates.find(
(candidate) =>
candidateLabel(candidate) &&
!isPrivateTenantCandidate(candidate, knownTenants),
);
return candidateLabel(representative ?? {});
} }
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect { function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
@@ -467,10 +528,10 @@ function UserListPage() {
name_email: (user) => name_email: (user) =>
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`, `${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
tenant_dept: (user) => tenant_dept: (user) =>
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`, `${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
}, },
), ),
[userSchema], [tenants, userSchema],
); );
const items = React.useMemo(() => { const items = React.useMemo(() => {
if (!sortConfig) { if (!sortConfig) {
@@ -1019,8 +1080,9 @@ function UserListPage() {
virtualRows.map((virtualRow) => { virtualRows.map((virtualRow) => {
const user = items[virtualRow.index]; const user = items[virtualRow.index];
if (!user) return null; if (!user) return null;
const additionalTenantLabels = const representativeTenantLabel =
collectAdditionalTenantLabels(user); resolveRepresentativeTenantLabel(user, tenants) ||
t("ui.common.unassigned", "미배정");
return ( return (
<TableRow <TableRow
@@ -1151,27 +1213,13 @@ function UserListPage() {
<TableCell> <TableCell>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{user.tenant?.name || {representativeTenantLabel}
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
</span> </span>
{user.department && ( {user.department && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{user.department} {user.department}
</span> </span>
)} )}
{additionalTenantLabels.length > 0 && (
<div className="flex flex-wrap gap-1">
{additionalTenantLabels.map((label) => (
<span
key={label}
className="max-w-40 truncate rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
</div> </div>
</TableCell> </TableCell>
{/* Dynamic Metadata Cells */} {/* Dynamic Metadata Cells */}

View File

@@ -348,9 +348,13 @@ update_error = "Failed to User Edit."
update_success = "Update Success" update_success = "Update Success"
[msg.admin.users.detail.custom_claims] [msg.admin.users.detail.custom_claims]
description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen." description = "Manage this user's values for globally defined custom claims. Read/Write indicates whether the user may view or update their own claim value. Add claim definitions and change types only from the global settings screen."
empty = "No global custom claims have been defined." empty = "No global custom claims have been defined."
[msg.admin.users.global_custom_claims]
description = "Manage user claim definitions shared across all RPs and the default user read/write permissions. Enabling write also enables read."
registry = "Only defined claim keys are available in per-user global claim values. Read/Write is a user self-service permission, not an administrator permission."
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "Required." field_required = "Required."
invalid_format = "Invalid format." invalid_format = "Invalid format."

View File

@@ -353,9 +353,13 @@ update_success = "사용자 정보가 수정되었습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다." self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[msg.admin.users.detail.custom_claims] [msg.admin.users.detail.custom_claims]
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다." description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
empty = "전역으로 정의된 custom claim이 없습니다." empty = "전역으로 정의된 custom claim이 없습니다."
[msg.admin.users.global_custom_claims]
description = "모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다."
registry = "정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다."
[msg.admin.users.detail.form] [msg.admin.users.detail.form]
field_required = "필수입니다." field_required = "필수입니다."
invalid_format = "형식이 올바르지 않습니다." invalid_format = "형식이 올바르지 않습니다."

View File

@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
const tenants = [ const tenants = [
{ {
id: "seed-hanmac", id: "038326b6-954a-48a7-a85f-efd83f62b82a",
name: "한맥가족", name: "한맥가족",
slug: "hanmac-family", slug: "hanmac-family",
type: "COMPANY_GROUP", type: "COMPANY_GROUP",
@@ -13,6 +13,19 @@ const tenants = [
createdAt: "", createdAt: "",
updatedAt: "", updatedAt: "",
}, },
{
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
name: "한라산업개발",
slug: "hallasanup",
type: "COMPANY",
description: "네이버웍스 한라 HALLA_DOMAIN_ID",
status: "active",
domains: ["hallasanup.com"],
memberCount: 0,
parentId: "038326b6-954a-48a7-a85f-efd83f62b82a",
createdAt: "",
updatedAt: "",
},
{ {
id: "normal-tenant", id: "normal-tenant",
name: "일반 테넌트", name: "일반 테넌트",
@@ -96,11 +109,21 @@ test.describe("Seed tenant protection", () => {
}) => { }) => {
await page.goto("/tenants"); await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ }); const seedRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "한맥가족", exact: true }),
});
await expect(seedRow.getByRole("checkbox")).toHaveCount(0); await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible(); await expect(seedRow.getByText("초기 설정")).toBeVisible();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ }); const hallaRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "한라산업개발", exact: true }),
});
await expect(hallaRow.getByRole("checkbox")).toHaveCount(0);
await expect(hallaRow.getByText("초기 설정")).toBeVisible();
const normalRow = page.getByRole("row").filter({
has: page.getByRole("link", { name: "일반 테넌트", exact: true }),
});
await expect(normalRow.getByRole("checkbox")).toBeEnabled(); await expect(normalRow.getByRole("checkbox")).toBeEnabled();
}); });

View File

@@ -293,6 +293,94 @@ test.describe("User Management", () => {
}); });
}); });
test("should hide private representative tenants in the user list row", async ({
page,
}) => {
await page.route(/\/admin\/tenants(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
type: "USER_GROUP",
status: "active",
config: { visibility: "private" },
},
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
type: "USER_GROUP",
status: "active",
config: { visibility: "public" },
},
],
total: 2,
limit: 100,
offset: 0,
},
});
});
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
json: {
items: [
{
id: "u-private",
name: "Private Primary User",
email: "private-primary@example.com",
phone: "010-0000-0000",
loginId: "private-primary",
role: "user",
status: "active",
tenantSlug: "private-team",
tenant: {
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
joinedTenants: [
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
metadata: {
primaryTenantId: "tenant-private",
primaryTenantSlug: "private-team",
primaryTenantName: "비공개 팀",
},
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
},
],
total: 1,
limit: 50,
offset: 0,
},
});
});
await page.goto("/users");
const row = page.getByRole("row").filter({
hasText: "Private Primary User",
});
await expect(row).toContainText("공개 팀");
await expect(row).not.toContainText("비공개 팀");
});
test("should successfully edit a user's Login ID", async ({ page }) => { test("should successfully edit a user's Login ID", async ({ page }) => {
await page.goto("/users/u-1"); await page.goto("/users/u-1");

View File

@@ -14,7 +14,9 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -51,6 +53,10 @@ func SeedTenants(db *gorm.DB) error {
return errors.New("seed tenant csv has no tenant rows") return errors.New("seed tenant csv has no tenant rows")
} }
if err := syncExistingSeedTenantConfigs(db, configs); err != nil {
return err
}
existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db) existingSlugs, existingIDs, err := loadExistingTenantIdentitySet(db)
if err != nil { if err != nil {
return err return err
@@ -71,6 +77,69 @@ func SeedTenants(db *gorm.DB) error {
return seedTenantConfigs(db, missingConfigs) return seedTenantConfigs(db, missingConfigs)
} }
func syncExistingSeedTenantConfigs(db *gorm.DB, configs []InitialTenantConfig) error {
for _, config := range configs {
id := strings.TrimSpace(config.TenantID)
if id == "" {
continue
}
targetSlug := strings.TrimSpace(strings.ToLower(config.Slug))
if targetSlug == "" {
continue
}
if err := db.Transaction(func(tx *gorm.DB) error {
var tenant domain.Tenant
if err := tx.First(&tenant, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("load existing seed tenant %q: %w", id, err)
}
if strings.TrimSpace(strings.ToLower(tenant.Slug)) == targetSlug {
return nil
}
var conflict domain.Tenant
err := tx.Select("id").
Where("LOWER(TRIM(slug)) = ? AND id <> ?", targetSlug, tenant.ID).
First(&conflict).Error
if err == nil {
return fmt.Errorf("seed tenant slug %q for id %q conflicts with tenant id %q", targetSlug, id, conflict.ID)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("check seed tenant slug conflict %q: %w", targetSlug, err)
}
suffix := "-deleted-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
if err := tx.Unscoped().
Model(&domain.Tenant{}).
Where("slug = ? AND id <> ? AND deleted_at IS NOT NULL", targetSlug, tenant.ID).
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
return fmt.Errorf("rename deleted tenant slug %q before seed repair: %w", targetSlug, err)
}
slog.Info(
"[Bootstrap] Repairing existing seed tenant slug",
"id", tenant.ID,
"oldSlug", tenant.Slug,
"newSlug", targetSlug,
)
if err := tx.Model(&domain.Tenant{}).
Where("id = ?", tenant.ID).
Update("slug", targetSlug).Error; err != nil {
return fmt.Errorf("repair seed tenant slug %q for id %q: %w", targetSlug, id, err)
}
return nil
}); err != nil {
return err
}
}
return nil
}
func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) { func loadExistingTenantIdentitySet(db *gorm.DB) (map[string]bool, map[string]bool, error) {
var tenants []domain.Tenant var tenants []domain.Tenant
if err := db.Select("id", "slug").Find(&tenants).Error; err != nil { if err := db.Select("id", "slug").Find(&tenants).Error; err != nil {

View File

@@ -273,7 +273,7 @@ func TestFilterMissingSeedTenantConfigsSkipsExistingSlugs(t *testing.T) {
} }
} }
func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testing.T) { func TestSeedTenantsCreatesMissingSeedRowsAndRepairsExistingSeedSlug(t *testing.T) {
if !testsupport.DockerAvailable() { if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment") t.Skip("Docker provider is unavailable in this environment")
} }
@@ -326,18 +326,30 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
Type: domain.TenantTypeCompany, Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive, Status: domain.TenantStatusActive,
} }
existingSeedTenantWithTypoSlug := domain.Tenant{
ID: "5a03efd2-e62f-4243-800d-58334bf48b2f",
Name: "한라산업개발",
Slug: "hanlla",
Type: domain.TenantTypeCompany,
Description: "seed tenant with a typo slug must be repaired by UUID",
Status: domain.TenantStatusActive,
ParentID: &existingRoot.ID,
}
if err := db.Create(&existingRoot).Error; err != nil { if err := db.Create(&existingRoot).Error; err != nil {
t.Fatalf("failed to create existing root tenant: %v", err) t.Fatalf("failed to create existing root tenant: %v", err)
} }
if err := db.Create(&nonSeedTenant).Error; err != nil { if err := db.Create(&nonSeedTenant).Error; err != nil {
t.Fatalf("failed to create non-seed tenant: %v", err) t.Fatalf("failed to create non-seed tenant: %v", err)
} }
if err := db.Create(&existingSeedTenantWithTypoSlug).Error; err != nil {
t.Fatalf("failed to create existing seed tenant with typo slug: %v", err)
}
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv") path := filepath.Join(dir, "seed-tenant.csv")
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" + csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" + "10000000-0000-0000-0000-000000000001,Seed Root Name,COMPANY_GROUP,,existing-root,seed must be skipped,\n" +
"00000000-0000-0000-0000-000000000002,Conflicting ID,COMPANY,existing-root,conflicting-id,seed id must be skipped,\n" + "5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,existing-root,halla,seed typo slug must be repaired,hallasanup.com\n" +
"10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n" "10000000-0000-0000-0000-000000000002,Missing Child,COMPANY,existing-root,missing-child,created from seed,child.example.com\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil { if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err) t.Fatalf("failed to write seed csv: %v", err)
@@ -359,6 +371,22 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name) t.Fatalf("existing root name = %q, want untouched %q", root.Name, existingRoot.Name)
} }
var manual domain.Tenant
if err := db.First(&manual, "id = ?", nonSeedTenant.ID).Error; err != nil {
t.Fatalf("failed to load non-seed tenant after seed: %v", err)
}
if manual.Slug != nonSeedTenant.Slug {
t.Fatalf("non-seed tenant slug = %q, want untouched %q", manual.Slug, nonSeedTenant.Slug)
}
var repairedSeed domain.Tenant
if err := db.First(&repairedSeed, "id = ?", existingSeedTenantWithTypoSlug.ID).Error; err != nil {
t.Fatalf("failed to load existing seed tenant after seed: %v", err)
}
if repairedSeed.Slug != "halla" {
t.Fatalf("existing seed tenant slug = %q, want halla", repairedSeed.Slug)
}
var child domain.Tenant var child domain.Tenant
if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil { if err := db.Preload("Domains").First(&child, "slug = ?", "missing-child").Error; err != nil {
t.Fatalf("missing seed child was not created: %v", err) t.Fatalf("missing seed child was not created: %v", err)
@@ -378,11 +406,4 @@ func TestSeedTenantsCreatesMissingSeedRowsWithoutTouchingExistingSlugs(t *testin
t.Fatalf("existing-root row count = %d, want 1", rootCount) t.Fatalf("existing-root row count = %d, want 1", rootCount)
} }
var conflictingIDCount int64
if err := db.Model(&domain.Tenant{}).Where("slug = ?", "conflicting-id").Count(&conflictingIDCount).Error; err != nil {
t.Fatalf("failed to count conflicting-id rows: %v", err)
}
if conflictingIDCount != 0 {
t.Fatalf("conflicting-id row count = %d, want 0", conflictingIDCount)
}
} }

View File

@@ -17,6 +17,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"maps" "maps"
"math"
"math/rand" "math/rand"
"net" "net"
"net/http" "net/http"
@@ -1646,6 +1647,154 @@ func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string
return baseClaims return baseClaims
} }
func (h *AuthHandler) withRPUserMetadataClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
if claims == nil {
claims = map[string]any{}
}
if h == nil || h.RPUserMetadataRepo == nil {
return claims
}
clientID := strings.TrimSpace(client.ClientID)
subject = strings.TrimSpace(subject)
if clientID == "" || subject == "" {
return claims
}
rpClaimDefinitions := extractRPClaimDefinitions(client.Metadata)
if len(rpClaimDefinitions) == 0 {
return claims
}
row, err := h.RPUserMetadataRepo.Get(ctx, clientID, subject)
if err != nil || row == nil || len(row.Metadata) == 0 {
return claims
}
rpClaims, _ := claims["rp_claims"].(map[string]any)
if rpClaims == nil {
rpClaims = map[string]any{}
}
for _, claim := range rpClaimDefinitions {
raw, ok := row.Metadata[claim.Key]
if !ok || raw == nil {
continue
}
value, ok := coerceRPUserMetadataClaimValue(raw, claim.ValueType)
if !ok {
slog.Warn("failed to coerce rp user metadata claim", "client_id", clientID, "subject", subject, "key", claim.Key, "value_type", claim.ValueType)
continue
}
rpClaims[claim.Key] = value
}
if len(rpClaims) > 0 {
claims["rp_claims"] = rpClaims
}
return claims
}
func extractRPClaimDefinitions(metadata map[string]any) []normalizedIDTokenClaim {
if metadata == nil {
return nil
}
rawClaims, ok := metadata[domain.MetadataIDTokenClaims]
if !ok || rawClaims == nil {
return nil
}
normalizedClaims, err := normalizeIDTokenClaims(rawClaims)
if err != nil {
slog.Warn("failed to normalize rp claim definitions", "error", err)
return nil
}
definitions := make([]normalizedIDTokenClaim, 0, len(normalizedClaims))
seen := make(map[string]struct{}, len(normalizedClaims))
for _, claim := range normalizedClaims {
if claim.Namespace != "rp_claims" {
continue
}
if _, exists := seen[claim.Key]; exists {
continue
}
seen[claim.Key] = struct{}{}
definitions = append(definitions, claim)
}
return definitions
}
func coerceRPUserMetadataClaimValue(raw any, valueType string) (any, bool) {
switch value := raw.(type) {
case string:
if strings.TrimSpace(value) == "" {
return nil, false
}
parsed, err := parseConfiguredClaimValue(value, valueType)
return parsed, err == nil
case []any:
if valueType == "array" {
return value, true
}
case []string:
if valueType == "array" {
items := make([]any, 0, len(value))
for _, item := range value {
items = append(items, item)
}
return items, true
}
case map[string]any:
if valueType == "object" {
return value, true
}
case bool:
if valueType == "boolean" {
return value, true
}
case float64:
if valueType == "float" {
return value, true
}
if valueType == "number" && value == math.Trunc(value) {
return value, true
}
case float32:
floatValue := float64(value)
if valueType == "float" {
return floatValue, true
}
if valueType == "number" && floatValue == math.Trunc(floatValue) {
return floatValue, true
}
case int:
if valueType == "number" {
return float64(value), true
}
if valueType == "float" {
return float64(value), true
}
case int64:
if valueType == "number" {
return float64(value), true
}
if valueType == "float" {
return float64(value), true
}
case json.Number:
if valueType == "number" {
parsed, err := value.Int64()
return float64(parsed), err == nil
}
if valueType == "float" {
parsed, err := value.Float64()
return parsed, err == nil
}
}
parsed, err := parseConfiguredClaimValue(fmt.Sprint(raw), valueType)
return parsed, err == nil
}
func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any { func (h *AuthHandler) withRPProfileClaims(ctx context.Context, claims map[string]any, client domain.HydraClient, subject string) map[string]any {
if claims == nil { if claims == nil {
claims = map[string]any{} claims = map[string]any{}
@@ -6046,6 +6195,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
currentSessionID, currentSessionID,
) )
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims)
if err == nil { if err == nil {
@@ -6084,6 +6234,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
currentSessionID, currentSessionID,
) )
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시)
@@ -6275,6 +6426,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
currentSessionID, currentSessionID,
) )
sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope)
sessionClaims = h.withRPUserMetadataClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject)
// [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용)

View File

@@ -827,3 +827,148 @@ func TestAcceptConsentRequest_AppliesConfiguredIDTokenClaims(t *testing.T) {
assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"]) assert.Equal(t, []any{"sso", "claims"}, rpClaims["features"])
} }
} }
func TestAcceptConsentRequest_UsesUpdatedRPUserMetadataForRPClaims(t *testing.T) {
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-rp-user-claims" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": "challenge-rp-user-claims",
"requested_scope": []string{"openid", "profile"},
"subject": "user-rp-claims",
"client": map[string]any{
"client_id": "client-rp-claims",
"metadata": map[string]any{
"id_token_claims": []map[string]any{
{
"namespace": "rp_claims",
"key": "approvalLevel",
"value": "A",
"valueType": "text",
},
{
"namespace": "rp_claims",
"key": "activeMember",
"value": "true",
"valueType": "boolean",
},
{
"namespace": "rp_claims",
"key": "score",
"value": "1",
"valueType": "number",
},
{
"namespace": "rp_claims",
"key": "featureList",
"value": `["default"]`,
"valueType": "array",
},
{
"namespace": "rp_claims",
"key": "preferences",
"value": `{"theme":"light","density":"comfortable"}`,
"valueType": "object",
},
{
"namespace": "rp_claims",
"key": "contractDate",
"value": "2026-06-09",
"valueType": "date",
},
{
"namespace": "rp_claims",
"key": "approvedAt",
"value": "2026-06-09T09:30",
"valueType": "datetime",
},
},
},
},
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-rp-user-claims" {
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}
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
}
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-rp-claims").Return(&service.KratosIdentity{
ID: "user-rp-claims",
Traits: map[string]any{
"email": "rp-user@example.com",
"name": "RP User",
},
}, nil)
repo := new(devMockRPUserMetadataRepo)
repo.On("Get", mock.Anything, "client-rp-claims", "user-rp-claims").Return(&domain.RPUserMetadata{
ClientID: "client-rp-claims",
UserID: "user-rp-claims",
Metadata: domain.JSONMap{
"approvalLevel": "B",
"activeMember": false,
"score": float64(42),
"featureList": []any{"sso", "claims"},
"preferences": map[string]any{
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"internalMemo": "must-not-leak",
"approvalLevel_permissions": map[string]any{
"readPermission": "admin_only",
"writePermission": "user_and_admin",
},
},
}, nil).Once()
h.RPUserMetadataRepo = repo
app := fiber.New()
app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest)
reqBody, _ := json.Marshal(map[string]any{
"consent_challenge": "challenge-rp-user-claims",
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.NotNil(t, capturedClaims)
rpClaims, ok := capturedClaims["rp_claims"].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, "B", rpClaims["approvalLevel"])
assert.Equal(t, false, rpClaims["activeMember"])
assert.Equal(t, float64(42), rpClaims["score"])
assert.Equal(t, []any{"sso", "claims"}, rpClaims["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, rpClaims["preferences"])
assert.Equal(t, "2026-06-10", rpClaims["contractDate"])
assert.Equal(t, "2026-06-09T10:30", rpClaims["approvedAt"])
assert.NotContains(t, rpClaims, "internalMemo")
assert.NotContains(t, rpClaims, "approvalLevel_permissions")
}
assert.NotContains(t, capturedClaims, "rp_profiles")
repo.AssertExpectations(t)
}

View File

@@ -3588,7 +3588,7 @@ func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]nor
valueType = "text" valueType = "text"
} }
switch valueType { switch valueType {
case "text", "number", "boolean", "array", "object", "date", "datetime": case "text", "number", "float", "boolean", "array", "object", "date", "datetime":
default: default:
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType) return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
} }
@@ -3641,9 +3641,24 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
if trimmed == "" { if trimmed == "" {
return nil, errors.New("number value is required") return nil, errors.New("number value is required")
} }
if !isIntegerClaimLiteral(trimmed) {
return nil, errors.New("number value must be an integer")
}
parsed, err := strconv.ParseFloat(trimmed, 64) parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil { if err != nil {
return nil, errors.New("number value must be a finite number") return nil, errors.New("number value must be an integer")
}
return parsed, nil
case "float":
if trimmed == "" {
return nil, errors.New("float value is required")
}
if !isFloatClaimLiteral(trimmed) {
return nil, errors.New("float value must be a finite decimal number")
}
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, errors.New("float value must be a finite decimal number")
} }
return parsed, nil return parsed, nil
case "boolean": case "boolean":
@@ -3708,6 +3723,54 @@ func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) {
} }
} }
func isIntegerClaimLiteral(value string) bool {
if value == "" {
return false
}
start := 0
if value[0] == '-' {
if len(value) == 1 {
return false
}
start = 1
}
for _, char := range value[start:] {
if char < '0' || char > '9' {
return false
}
}
return true
}
func isFloatClaimLiteral(value string) bool {
if value == "" {
return false
}
start := 0
if value[0] == '-' {
if len(value) == 1 {
return false
}
start = 1
}
hasDigit := false
hasDot := false
for _, char := range value[start:] {
switch {
case char >= '0' && char <= '9':
hasDigit = true
case char == '.':
if hasDot {
return false
}
hasDot = true
default:
return false
}
}
return hasDigit
}
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool { func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
if req.Jwks != nil { if req.Jwks != nil {
return true return true

View File

@@ -60,6 +60,30 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"valueType": "number", "valueType": "number",
"value": "1", "value": "1",
}, },
{
"namespace": "rp_claims",
"key": "featureList",
"valueType": "array",
"value": `["default"]`,
},
{
"namespace": "rp_claims",
"key": "preferences",
"valueType": "object",
"value": `{"theme":"light","density":"comfortable"}`,
},
{
"namespace": "rp_claims",
"key": "contractDate",
"valueType": "date",
"value": "2026-06-09",
},
{
"namespace": "rp_claims",
"key": "approvedAt",
"valueType": "datetime",
"value": "2026-06-09T09:30",
},
}, },
}, },
}), nil }), nil
@@ -74,8 +98,14 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
row.Metadata["approvalLevel"] == "A" && row.Metadata["approvalLevel"] == "A" &&
row.Metadata["activeMember"] == false && row.Metadata["activeMember"] == false &&
row.Metadata["score"] == float64(42) && row.Metadata["score"] == float64(42) &&
assert.ObjectsAreEqual([]any{"sso", "claims"}, row.Metadata["featureList"]) &&
assert.ObjectsAreEqual(map[string]any{"theme": "dark", "density": "compact"}, row.Metadata["preferences"]) &&
row.Metadata["contractDate"] == "2026-06-10" &&
row.Metadata["approvedAt"] == "2026-06-09T10:30" &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" && row.Metadata["approvalLevel_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" row.Metadata["approvalLevel_permissions"].(map[string]any)["writePermission"] == "user_and_admin" &&
row.Metadata["featureList_permissions"].(map[string]any)["readPermission"] == "admin_only" &&
row.Metadata["featureList_permissions"].(map[string]any)["writePermission"] == "admin_only"
})).Return(nil).Once() })).Return(nil).Once()
repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{ repo.On("Get", mock.Anything, "client-1", "user-1").Return(&domain.RPUserMetadata{
ClientID: "client-1", ClientID: "client-1",
@@ -103,6 +133,13 @@ func TestDevHandler_RPUserMetadataRoundTrip(t *testing.T) {
"approvalLevel": "A", "approvalLevel": "A",
"activeMember": false, "activeMember": false,
"score": 42, "score": 42,
"featureList": []string{"sso", "claims"},
"preferences": map[string]any{
"theme": "dark",
"density": "compact",
},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"approvalLevel_permissions": map[string]any{ "approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin", "writePermission": "user_and_admin",
}, },

View File

@@ -2520,6 +2520,13 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
"value": "2", "value": "2",
"valueType": "number", "valueType": "number",
}, },
{
"id": "claim-3",
"namespace": "rp_claims",
"key": "ratio",
"value": "3.14",
"valueType": "float",
},
}, },
}, },
}) })
@@ -2530,7 +2537,7 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.Equal(t, http.StatusCreated, resp.StatusCode)
claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any) claims, ok := captured.Metadata[domain.MetadataIDTokenClaims].([]any)
if assert.True(t, ok) && assert.Len(t, claims, 2) { if assert.True(t, ok) && assert.Len(t, claims, 3) {
first, ok := claims[0].(map[string]any) first, ok := claims[0].(map[string]any)
if assert.True(t, ok) { if assert.True(t, ok) {
assert.Equal(t, "rp_claims", first["namespace"]) assert.Equal(t, "rp_claims", first["namespace"])
@@ -2548,6 +2555,14 @@ func TestCreateClient_NormalizesIDTokenClaimsMetadata(t *testing.T) {
assert.Equal(t, "2", second["value"]) assert.Equal(t, "2", second["value"])
assert.Equal(t, "number", second["valueType"]) assert.Equal(t, "number", second["valueType"])
} }
third, ok := claims[2].(map[string]any)
if assert.True(t, ok) {
assert.Equal(t, "rp_claims", third["namespace"])
assert.Equal(t, "ratio", third["key"])
assert.Equal(t, "3.14", third["value"])
assert.Equal(t, "float", third["valueType"])
}
} }
} }

View File

@@ -0,0 +1,328 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type rpClaimsE2ERepo struct {
mu sync.Mutex
rows map[string]*domain.RPUserMetadata
getKeys []string
}
func newRPClaimsE2ERepo() *rpClaimsE2ERepo {
return &rpClaimsE2ERepo{rows: map[string]*domain.RPUserMetadata{}}
}
func (r *rpClaimsE2ERepo) Get(ctx context.Context, clientID, userID string) (*domain.RPUserMetadata, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := rpClaimsE2ERepoKey(clientID, userID)
r.getKeys = append(r.getKeys, key)
row, ok := r.rows[key]
if !ok {
return nil, fmt.Errorf("rp user metadata not found")
}
return cloneRPUserMetadata(row), nil
}
func (r *rpClaimsE2ERepo) Upsert(ctx context.Context, metadata *domain.RPUserMetadata) error {
r.mu.Lock()
defer r.mu.Unlock()
r.rows[rpClaimsE2ERepoKey(metadata.ClientID, metadata.UserID)] = cloneRPUserMetadata(metadata)
return nil
}
func (r *rpClaimsE2ERepo) seenGet(clientID, userID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
key := rpClaimsE2ERepoKey(clientID, userID)
for _, seen := range r.getKeys {
if seen == key {
return true
}
}
return false
}
func rpClaimsE2ERepoKey(clientID, userID string) string {
return strings.TrimSpace(clientID) + "\x00" + strings.TrimSpace(userID)
}
func cloneRPUserMetadata(row *domain.RPUserMetadata) *domain.RPUserMetadata {
if row == nil {
return nil
}
cloned := &domain.RPUserMetadata{
ClientID: row.ClientID,
UserID: row.UserID,
Metadata: domain.JSONMap{},
}
for key, value := range row.Metadata {
cloned.Metadata[key] = value
}
return cloned
}
func TestRPClaimsE2E_UpdatedClaimsAreScopedToCurrentRP(t *testing.T) {
const userID = "user-rp-e2e"
const clientA = "client-rp-a"
const clientB = "client-rp-b"
clients := map[string]map[string]any{
clientA: rpClaimsE2EClient(clientA, []map[string]any{
rpClaimsE2EClaim("approvalLevel", "text", "A", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("activeMember", "boolean", "true", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("score", "number", "1", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("featureList", "array", `["default"]`, "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("preferences", "object", `{"theme":"light","density":"comfortable"}`, "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("contractDate", "date", "2026-06-09", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("approvedAt", "datetime", "2026-06-09T09:30", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("adminManagedNote", "text", "admin-default", "user_and_admin", "admin_only"),
}),
clientB: rpClaimsE2EClient(clientB, []map[string]any{
rpClaimsE2EClaim("approvalLevel", "text", "B-default", "user_and_admin", "user_and_admin"),
rpClaimsE2EClaim("activeMember", "boolean", "false", "user_and_admin", "user_and_admin"),
}),
}
challenges := map[string]string{
"challenge-client-a-default": clientA,
"challenge-client-a-admin-update": clientA,
"challenge-client-a-self-update": clientA,
"challenge-client-b-default": clientB,
"challenge-client-b-update": clientB,
}
capturedClaims := map[string]map[string]any{}
hydraClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if strings.HasPrefix(r.URL.Path, "/clients/") {
clientID := strings.TrimPrefix(r.URL.Path, "/clients/")
client, ok := clients[clientID]
if !ok {
return httpJSONAny(r, http.StatusNotFound, nil), nil
}
return httpJSONAny(r, http.StatusOK, client), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent" {
challenge := r.URL.Query().Get("consent_challenge")
clientID, ok := challenges[challenge]
if !ok {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"challenge": challenge,
"requested_scope": []string{"openid", "profile"},
"subject": userID,
"client": clients[clientID],
}), nil
}
if r.URL.Path == "/oauth2/auth/requests/consent/accept" {
challenge := r.URL.Query().Get("consent_challenge")
body, _ := io.ReadAll(r.Body)
var acceptReq map[string]any
_ = json.Unmarshal(body, &acceptReq)
session, _ := acceptReq["session"].(map[string]any)
idToken, _ := session["id_token"].(map[string]any)
capturedClaims[challenge] = idToken
return httpJSONAny(r, http.StatusOK, map[string]any{
"redirect_to": "http://rp/cb",
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
repo := newRPClaimsE2ERepo()
kratos := new(MockKratosAdminService)
kratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
ID: userID,
State: "active",
Traits: map[string]any{
"email": "rp-e2e@example.com",
"name": "RP E2E User",
},
}, nil)
authHandler := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: hydraClient,
},
KratosAdmin: kratos,
RPUserMetadataRepo: repo,
}
devHandler := &DevHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: hydraClient,
},
RPUserMetadataRepo: repo,
}
app := fiber.New()
app.Put("/api/v1/dev/clients/:id/users/me/metadata", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: userID, Role: domain.RoleUser})
return devHandler.SelfUpdateRPUserMetadata(c)
})
app.Put("/api/v1/dev/clients/:id/users/:userId/metadata", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin", Role: domain.RoleSuperAdmin})
return devHandler.UpsertRPUserMetadata(c)
})
app.Post("/api/v1/auth/consent/accept", authHandler.AcceptConsentRequest)
initialA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-default")
assert.Equal(t, "A", initialA["approvalLevel"])
assert.Equal(t, true, initialA["activeMember"])
assert.Equal(t, float64(1), initialA["score"])
assert.Equal(t, []any{"default"}, initialA["featureList"])
assert.Equal(t, map[string]any{"theme": "light", "density": "comfortable"}, initialA["preferences"])
assert.Equal(t, "2026-06-09", initialA["contractDate"])
assert.Equal(t, "2026-06-09T09:30", initialA["approvedAt"])
upsertRPClaimsE2EMetadata(t, app, clientA, userID, map[string]any{
"approvalLevel": "B",
"activeMember": false,
"score": 42,
"featureList": []string{"sso", "claims"},
"preferences": map[string]any{"theme": "dark", "density": "compact"},
"contractDate": "2026-06-10",
"approvedAt": "2026-06-09T10:30",
"adminManagedNote": "admin-updated",
"approvalLevel_permissions": map[string]any{
"writePermission": "user_and_admin",
},
})
updatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-admin-update")
assert.Equal(t, "B", updatedA["approvalLevel"])
assert.Equal(t, false, updatedA["activeMember"])
assert.Equal(t, float64(42), updatedA["score"])
assert.Equal(t, []any{"sso", "claims"}, updatedA["featureList"])
assert.Equal(t, map[string]any{"theme": "dark", "density": "compact"}, updatedA["preferences"])
assert.Equal(t, "2026-06-10", updatedA["contractDate"])
assert.Equal(t, "2026-06-09T10:30", updatedA["approvedAt"])
assert.Equal(t, "admin-updated", updatedA["adminManagedNote"])
assert.NotContains(t, updatedA, "approvalLevel_permissions")
assert.NotContains(t, updatedA, "adminManagedNote_permissions")
rejectedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
"metadata": map[string]any{
"adminManagedNote": "user-should-not-overwrite",
},
})
assert.Equal(t, http.StatusForbidden, rejectedSelfUpdate.StatusCode)
allowedSelfUpdate := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientA+"/users/me/metadata", map[string]any{
"metadata": map[string]any{
"approvalLevel": "C",
},
})
assert.Equal(t, http.StatusOK, allowedSelfUpdate.StatusCode)
selfUpdatedA := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-a-self-update")
assert.Equal(t, "C", selfUpdatedA["approvalLevel"])
assert.Equal(t, "admin-updated", selfUpdatedA["adminManagedNote"])
defaultB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-default")
assert.Equal(t, "B-default", defaultB["approvalLevel"])
assert.Equal(t, false, defaultB["activeMember"])
assert.NotContains(t, defaultB, "score")
assert.NotContains(t, defaultB, "featureList")
assert.NotContains(t, defaultB, "adminManagedNote")
upsertRPClaimsE2EMetadata(t, app, clientB, userID, map[string]any{
"approvalLevel": "B-rp-only",
"activeMember": true,
})
updatedB := acceptRPClaimsE2EConsent(t, app, capturedClaims, "challenge-client-b-update")
assert.Equal(t, "B-rp-only", updatedB["approvalLevel"])
assert.Equal(t, true, updatedB["activeMember"])
assert.NotEqual(t, selfUpdatedA["approvalLevel"], updatedB["approvalLevel"])
assert.NotContains(t, updatedB, "score")
assert.NotContains(t, updatedB, "featureList")
require.True(t, repo.seenGet(clientA, userID))
require.True(t, repo.seenGet(clientB, userID))
require.Contains(t, capturedClaims, "challenge-client-a-admin-update")
require.Contains(t, capturedClaims, "challenge-client-b-update")
kratos.AssertExpectations(t)
}
func rpClaimsE2EClient(clientID string, claims []map[string]any) map[string]any {
return map[string]any{
"client_id": clientID,
"client_name": clientID,
"metadata": map[string]any{
"tenant_id": "tenant-rp-e2e",
"id_token_claims": claims,
},
}
}
func rpClaimsE2EClaim(key, valueType, value, readPermission, writePermission string) map[string]any {
return map[string]any{
"namespace": "rp_claims",
"key": key,
"valueType": valueType,
"value": value,
"readPermission": readPermission,
"writePermission": writePermission,
}
}
func acceptRPClaimsE2EConsent(t *testing.T, app *fiber.App, capturedClaims map[string]map[string]any, challenge string) map[string]any {
t.Helper()
body, _ := json.Marshal(map[string]any{
"consent_challenge": challenge,
"grant_scope": []string{"openid", "profile"},
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
idToken, ok := capturedClaims[challenge]
require.True(t, ok)
rpClaims, ok := idToken["rp_claims"].(map[string]any)
require.True(t, ok)
return rpClaims
}
func upsertRPClaimsE2EMetadata(t *testing.T, app *fiber.App, clientID, userID string, metadata map[string]any) {
t.Helper()
resp := putRPClaimsE2EMetadata(t, app, http.MethodPut, "/api/v1/dev/clients/"+clientID+"/users/"+userID+"/metadata", map[string]any{
"metadata": metadata,
})
require.Equal(t, http.StatusOK, resp.StatusCode)
}
func putRPClaimsE2EMetadata(t *testing.T, app *fiber.App, method, path string, body map[string]any) *http.Response {
t.Helper()
payload, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req, -1)
require.NoError(t, err)
return resp
}

View File

@@ -52,6 +52,7 @@ import { ClientDetailTabs } from "./ClientDetailTabs";
type RPClaimValueType = type RPClaimValueType =
| "text" | "text"
| "number" | "number"
| "float"
| "boolean" | "boolean"
| "array" | "array"
| "object" | "object"
@@ -167,7 +168,9 @@ function draftRowsToMetadata(rows: MetadataDraftRow[]) {
function draftRowValueToMetadataValue(row: MetadataDraftRow) { function draftRowValueToMetadataValue(row: MetadataDraftRow) {
const value = row.value.trim(); const value = row.value.trim();
switch (row.valueType) { switch (row.valueType) {
case "number": { case "number":
return /^-?\d+$/.test(value) ? Number.parseInt(value, 10) : value;
case "float": {
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value; return Number.isFinite(parsed) ? parsed : value;
} }
@@ -200,6 +203,7 @@ function isRPClaimValueType(value: string): value is RPClaimValueType {
return ( return (
value === "text" || value === "text" ||
value === "number" || value === "number" ||
value === "float" ||
value === "boolean" || value === "boolean" ||
value === "array" || value === "array" ||
value === "object" || value === "object" ||
@@ -268,10 +272,21 @@ function readRPClaimSchemas(
function rpClaimInputType(valueType: RPClaimValueType) { function rpClaimInputType(valueType: RPClaimValueType) {
if (valueType === "date") return "date"; if (valueType === "date") return "date";
if (valueType === "datetime") return "datetime-local"; if (valueType === "datetime") return "datetime-local";
if (valueType === "number") return "number";
return "text"; return "text";
} }
function rpClaimInputMode(valueType: RPClaimValueType) {
if (valueType === "number") return "numeric";
if (valueType === "float") return "decimal";
return undefined;
}
function rpClaimInputPattern(valueType: RPClaimValueType) {
if (valueType === "number") return "-?[0-9]*";
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
return undefined;
}
function ClientConsentsPage() { function ClientConsentsPage() {
const params = useParams(); const params = useParams();
const clientId = params.id ?? ""; const clientId = params.id ?? "";
@@ -452,25 +467,6 @@ function ClientConsentsPage() {
); );
}; };
const addMetadataDraftRow = () => {
setMetadataDraftRows((current) => [
...current,
{
id: `rp-metadata-${Date.now()}`,
key: "",
value: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
schemaBacked: false,
},
]);
};
const removeMetadataDraftRow = (id: string) => {
setMetadataDraftRows((current) => current.filter((row) => row.id !== id));
};
if (error) { if (error) {
const axiosError = error as AxiosError<{ error?: string }>; const axiosError = error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) { if (axiosError.response?.status === 403) {
@@ -958,16 +954,6 @@ function ClientConsentsPage() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{rpClaimSchemas.length === 0 && (
<Button
variant="outline"
className="gap-2"
onClick={addMetadataDraftRow}
>
<Edit3 className="h-4 w-4" />
{t("ui.common.add", "추가")}
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
className="gap-2" className="gap-2"
@@ -1008,25 +994,9 @@ function ClientConsentsPage() {
key={row.id} key={row.id}
className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]" className="grid gap-3 md:grid-cols-[minmax(180px,0.8fr)_minmax(220px,1fr)_150px_150px_auto]"
> >
{row.schemaBacked ? ( <div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs"> {row.key}
{row.key} </div>
</div>
) : (
<Input
value={row.key}
onChange={(event) =>
updateMetadataDraftRow(row.id, {
key: event.target.value,
})
}
className="font-mono text-xs"
placeholder={t(
"ui.dev.clients.consents.rp_claims.key_placeholder",
"claim_key",
)}
/>
)}
{row.valueType === "boolean" ? ( {row.valueType === "boolean" ? (
<select <select
value={row.value === "false" ? "false" : "true"} value={row.value === "false" ? "false" : "true"}
@@ -1061,6 +1031,8 @@ function ClientConsentsPage() {
) : ( ) : (
<Input <Input
type={rpClaimInputType(row.valueType)} type={rpClaimInputType(row.valueType)}
inputMode={rpClaimInputMode(row.valueType)}
pattern={rpClaimInputPattern(row.valueType)}
value={row.value} value={row.value}
onChange={(event) => onChange={(event) =>
updateMetadataDraftRow(row.id, { updateMetadataDraftRow(row.id, {
@@ -1129,22 +1101,12 @@ function ClientConsentsPage() {
)} )}
</option> </option>
</select> </select>
{row.schemaBacked ? ( <Badge
<Badge variant="muted"
variant="muted" className="h-10 justify-center rounded-md px-3 font-mono text-xs"
className="h-10 justify-center rounded-md px-3 font-mono text-xs" >
> {row.valueType}
{row.valueType} </Badge>
</Badge>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => removeMetadataDraftRow(row.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div> </div>
)) ))
)} )}

View File

@@ -7,7 +7,7 @@ vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) => t: (key: string, fallback?: string) =>
({ ({
"ui.dev.clients.details.tab.connection": "연동 설정", "ui.dev.clients.details.tab.connection": "연동 설정",
"ui.dev.clients.details.tab.user_claims": "사용자 Claim", "ui.dev.clients.details.tab.consents": "Consents & Claims",
"ui.dev.clients.details.tab.settings": "설정", "ui.dev.clients.details.tab.settings": "설정",
"ui.dev.clients.details.tab.relationships": "관계", "ui.dev.clients.details.tab.relationships": "관계",
})[key] ?? })[key] ??
@@ -23,7 +23,7 @@ describe("ClientDetailTabs", () => {
</MemoryRouter>, </MemoryRouter>,
); );
expect(html).toContain("사용자 Claim"); expect(html).toContain("Consents &amp; Claims");
expect(html).toContain('href="/clients/client-a/consents"'); expect(html).toContain('href="/clients/client-a/consents"');
}); });
}); });

View File

@@ -18,7 +18,6 @@ const tabOrder: Array<{
{ {
key: "consents", key: "consents",
href: (clientId) => `/clients/${clientId}/consents`, href: (clientId) => `/clients/${clientId}/consents`,
labelKey: "ui.dev.clients.details.tab.user_claims",
}, },
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` }, { key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
{ {

View File

@@ -126,6 +126,26 @@ async function setInputValue(input: HTMLInputElement, value: string) {
await flush(); await flush();
} }
async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
);
descriptor?.set?.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await flush();
}
async function setSelectValue(select: HTMLSelectElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLSelectElement.prototype,
"value",
);
descriptor?.set?.call(select, value);
select.dispatchEvent(new Event("change", { bubbles: true }));
await flush();
}
async function renderPage() { async function renderPage() {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -229,4 +249,225 @@ describe("ClientGeneralPage RP claims", () => {
}, },
]); ]);
}); });
it("forces user read permission on when user write permission is enabled for RP claims", async () => {
const { container } = await renderPage();
const switches = Array.from(
container.querySelectorAll<HTMLButtonElement>('[role="switch"]'),
);
const readSwitch = switches.find((button) =>
/Read|읽기/.test(button.getAttribute("aria-label") ?? ""),
);
const writeSwitch = switches.find((button) =>
/Write|쓰기/.test(button.getAttribute("aria-label") ?? ""),
);
expect(readSwitch).toBeDefined();
expect(writeSwitch).toBeDefined();
expect(readSwitch?.getAttribute("aria-checked")).toBe("false");
expect(writeSwitch?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
writeSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(readSwitch?.getAttribute("aria-checked")).toBe("true");
expect(writeSwitch?.getAttribute("aria-checked")).toBe("true");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).toHaveBeenCalledWith(
"client-claims",
expect.objectContaining({
metadata: expect.objectContaining({
id_token_claims: [
expect.objectContaining({
readPermission: "user_and_admin",
writePermission: "user_and_admin",
}),
],
}),
}),
);
});
it("keeps nullable and default value as separate RP claim settings", async () => {
const { container } = await renderPage();
expect(container.textContent).toContain("Nullable");
expect(container.textContent).toContain("Default Value");
expect(container.textContent).not.toContain("Nullable/default");
expect(container.textContent).toContain(
"RP 전용 확장 claim을 구분해서 관리합니다",
);
});
it("blocks saving a number RP claim default value that is not numeric", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).not.toHaveBeenCalled();
});
it("blocks saving a number RP claim default value that is not an integer", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
const defaultValueInput = container.querySelector<HTMLInputElement>(
'input[placeholder="Enter the default value"]',
);
expect(defaultValueInput).not.toBeNull();
await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
expect(container.textContent).toContain(
"Claim 기본값이 타입과 맞지 않습니다",
);
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).not.toHaveBeenCalled();
});
it("saves a float RP claim default value", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
expect(
valueTypeSelect?.querySelector('option[value="float"]'),
).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "float");
const defaultValueInput = container.querySelector<HTMLInputElement>(
'input[placeholder="Enter the default value"]',
);
expect(defaultValueInput).not.toBeNull();
expect(defaultValueInput?.getAttribute("inputmode")).toBe("decimal");
await setInputValue(defaultValueInput as HTMLInputElement, "3.14");
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).toHaveBeenCalledWith(
"client-claims",
expect.objectContaining({
metadata: expect.objectContaining({
id_token_claims: [
expect.objectContaining({
value: "3.14",
valueType: "float",
}),
],
}),
}),
);
});
it("renders constrained default value controls for boolean and date RP claims", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean");
const booleanDefaultSelect = Array.from(
container.querySelectorAll<HTMLSelectElement>("select"),
).find((select) =>
Array.from(select.options).some((option) => option.value === "false"),
);
expect(booleanDefaultSelect).toBeDefined();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
expect(container.querySelector('input[type="date"]')).not.toBeNull();
});
it("blocks saving an object RP claim default value that is not a JSON object", async () => {
const { container } = await renderPage();
const valueTypeSelect = container.querySelector<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "object");
const defaultValueInput = container.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="{\\"key\\": \\"value\\"}"]',
);
expect(defaultValueInput).not.toBeNull();
await setTextareaValue(
defaultValueInput as HTMLTextAreaElement,
"not-json",
);
const saveButton = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("저장") ||
button.textContent?.includes("Save"),
);
expect(saveButton).toBeDefined();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(updateClientMock).not.toHaveBeenCalled();
});
}); });

View File

@@ -71,6 +71,7 @@ type ClaimNamespace = "rp_claims";
type ClaimValueType = type ClaimValueType =
| "text" | "text"
| "number" | "number"
| "float"
| "boolean" | "boolean"
| "array" | "array"
| "object" | "object"
@@ -149,6 +150,7 @@ function isClaimValueType(value: string): value is ClaimValueType {
return ( return (
value === "text" || value === "text" ||
value === "number" || value === "number" ||
value === "float" ||
value === "boolean" || value === "boolean" ||
value === "array" || value === "array" ||
value === "object" || value === "object" ||
@@ -176,6 +178,18 @@ function createIdTokenClaimItem(id: string): IdTokenClaimItem {
}; };
} }
function normalizeIdTokenClaimPermissions(
claim: IdTokenClaimItem,
): IdTokenClaimItem {
if (claim.writePermission !== "user_and_admin") {
return claim;
}
return {
...claim,
readPermission: "user_and_admin",
};
}
function readIdTokenClaimsMetadata( function readIdTokenClaimsMetadata(
metadata: Record<string, unknown>, metadata: Record<string, unknown>,
): IdTokenClaimItem[] { ): IdTokenClaimItem[] {
@@ -213,7 +227,7 @@ function readIdTokenClaimsMetadata(
? record.valueType ? record.valueType
: "text"; : "text";
return { return normalizeIdTokenClaimPermissions({
id: `claim-${index + 1}`, id: `claim-${index + 1}`,
namespace: namespaceValue, namespace: namespaceValue,
key: keyValue, key: keyValue,
@@ -226,7 +240,7 @@ function readIdTokenClaimsMetadata(
writePermission: isCustomClaimPermission(record.writePermission) writePermission: isCustomClaimPermission(record.writePermission)
? record.writePermission ? record.writePermission
: "admin_only", : "admin_only",
}; });
}) })
.filter((item): item is IdTokenClaimItem => item !== null); .filter((item): item is IdTokenClaimItem => item !== null);
} }
@@ -240,7 +254,7 @@ function normalizeClaimPreviewValue(
if (nullable && trimmed === "") { if (nullable && trimmed === "") {
return null; return null;
} }
if (valueType === "number") { if (valueType === "number" || valueType === "float") {
if (trimmed === "") return ""; if (trimmed === "") return "";
const parsed = Number(trimmed); const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : trimmed; return Number.isFinite(parsed) ? parsed : trimmed;
@@ -279,6 +293,137 @@ function normalizeClaimPreviewValue(
return trimmed; return trimmed;
} }
function isJsonObjectValue(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isIntegerClaimDefaultValue(value: string) {
return /^-?\d+$/.test(value);
}
function isFloatClaimDefaultValue(value: string) {
return /^-?(?:\d+(?:\.\d+)?|\.\d+)$/.test(value);
}
function isValidDateInputValue(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return false;
return date.toISOString().slice(0, 10) === value;
}
function isValidDateTimeInputValue(value: string) {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/.test(value)) {
return false;
}
const date = new Date(value);
return !Number.isNaN(date.getTime());
}
function claimDefaultValueValidationError(claim: IdTokenClaimItem) {
const value = claim.value.trim();
if (value === "") {
return null;
}
switch (claim.valueType) {
case "number":
return isIntegerClaimDefaultValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "float":
return isFloatClaimDefaultValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "boolean":
return value === "true" || value === "false"
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "array": {
try {
return Array.isArray(JSON.parse(value))
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
} catch {
return t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
}
}
case "object": {
try {
return isJsonObjectValue(JSON.parse(value))
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
} catch {
return t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
}
}
case "date":
return isValidDateInputValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
case "datetime":
return isValidDateTimeInputValue(value)
? null
: t(
"msg.dev.clients.general.id_token_claims.invalid_default_value",
"Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})",
{ key: claim.key || "-", valueType: claim.valueType },
);
default:
return null;
}
}
function claimDefaultInputType(valueType: ClaimValueType) {
if (valueType === "date") return "date";
if (valueType === "datetime") return "datetime-local";
return "text";
}
function claimDefaultInputMode(valueType: ClaimValueType) {
if (valueType === "number") return "numeric";
if (valueType === "float") return "decimal";
return undefined;
}
function claimDefaultInputPattern(valueType: ClaimValueType) {
if (valueType === "number") return "-?[0-9]*";
if (valueType === "float") return "-?(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)";
return undefined;
}
function buildIdTokenClaimsPreview( function buildIdTokenClaimsPreview(
items: IdTokenClaimItem[], items: IdTokenClaimItem[],
): Record<string, unknown> { ): Record<string, unknown> {
@@ -777,10 +922,10 @@ function ClientGeneralPage() {
if (claim.id !== id) { if (claim.id !== id) {
return claim; return claim;
} }
return { return normalizeIdTokenClaimPermissions({
...claim, ...claim,
[field]: permission, [field]: permission,
}; });
}), }),
); );
}; };
@@ -840,11 +985,13 @@ function ClientGeneralPage() {
"허용 알고리즘: {{algorithms}}", "허용 알고리즘: {{algorithms}}",
{ algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") }, { algorithms: HEADLESS_LOGIN_ALLOWED_ALGORITHMS.join(", ") },
); );
const normalizedIdTokenClaims = idTokenClaims.map((claim) => ({ const normalizedIdTokenClaims = idTokenClaims.map((claim) =>
...claim, normalizeIdTokenClaimPermissions({
key: claim.key.trim(), ...claim,
value: claim.value.trim(), key: claim.key.trim(),
})); value: claim.value.trim(),
}),
);
if (headlessLoginEnabled) { if (headlessLoginEnabled) {
if (!trimmedJwksUri) { if (!trimmedJwksUri) {
@@ -930,6 +1077,11 @@ function ClientGeneralPage() {
continue; continue;
} }
seenClaimKeys.add(keySignature); seenClaimKeys.add(keySignature);
const defaultValueError = claimDefaultValueValidationError(claim);
if (defaultValueError) {
claimValidationErrors.push(defaultValueError);
}
} }
validationErrors.push(...claimValidationErrors); validationErrors.push(...claimValidationErrors);
@@ -2103,7 +2255,7 @@ function ClientGeneralPage() {
<CardDescription> <CardDescription>
{t( {t(
"msg.dev.clients.general.id_token_claims.subtitle", "msg.dev.clients.general.id_token_claims.subtitle",
"공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다.", "RP 전용 확장 claim을 구분해서 관리합니다.",
)} )}
</CardDescription> </CardDescription>
</div> </div>
@@ -2151,13 +2303,13 @@ function ClientGeneralPage() {
<th className="px-4 py-3 text-left font-bold"> <th className="px-4 py-3 text-left font-bold">
{t( {t(
"ui.dev.clients.general.id_token_claims.table.read_user_allowed", "ui.dev.clients.general.id_token_claims.table.read_user_allowed",
"Read", "User read",
)} )}
</th> </th>
<th className="px-4 py-3 text-left font-bold"> <th className="px-4 py-3 text-left font-bold">
{t( {t(
"ui.dev.clients.general.id_token_claims.table.write_user_allowed", "ui.dev.clients.general.id_token_claims.table.write_user_allowed",
"Write", "User write",
)} )}
</th> </th>
<th className="px-4 py-3 text-left font-bold"> <th className="px-4 py-3 text-left font-bold">
@@ -2175,190 +2327,255 @@ function ClientGeneralPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{idTokenClaims.map((claim) => ( {idTokenClaims.map((claim) => {
<tr key={claim.id} className="hover:bg-muted/20"> const defaultValueError =
<td className="px-4 py-3 align-top"> claimDefaultValueValidationError(claim);
<Input
value={claim.key} return (
onChange={(e) => <tr key={claim.id} className="hover:bg-muted/20">
updateIdTokenClaim( <td className="px-4 py-3 align-top">
claim.id, <Input
"key", value={claim.key}
e.target.value, onChange={(e) =>
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)}
disabled={isGeneralSettingsReadOnly}
/>
</td>
<td className="px-4 py-3 align-top">
<Badge
variant="muted"
className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs"
>
{t(
"ui.dev.clients.general.id_token_claims.namespace_rp_claims",
"rp_claims",
)}
</Badge>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"valueType",
e.target.value as ClaimValueType,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.value_type_label",
"Claim value type",
)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="text">
{t(
"ui.dev.clients.general.id_token_claims.value_type_text",
"Text",
)}
</option>
<option value="number">
{t(
"ui.dev.clients.general.id_token_claims.value_type_number",
"Number",
)}
</option>
<option value="boolean">
{t(
"ui.dev.clients.general.id_token_claims.value_type_boolean",
"Boolean",
)}
</option>
<option value="array">
{t(
"ui.dev.clients.general.id_token_claims.value_type_array",
"Array",
)}
</option>
<option value="object">
{t(
"ui.dev.clients.general.id_token_claims.value_type_object",
"Object",
)}
</option>
<option value="date">
{t(
"ui.dev.clients.general.id_token_claims.value_type_date",
"Date",
)}
</option>
<option value="datetime">
{t(
"ui.dev.clients.general.id_token_claims.value_type_datetime",
"Datetime",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={claim.nullable}
onCheckedChange={(checked) =>
updateIdTokenClaim( updateIdTokenClaim(
claim.id, claim.id,
"nullable", "key",
checked, e.target.value,
) )
} }
aria-label={t( className="h-9 font-mono text-xs"
"ui.dev.clients.general.id_token_claims.nullable_label", placeholder={t(
"Nullable", "ui.dev.clients.general.id_token_claims.key_placeholder",
"e.g. locale",
)} )}
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
/> />
</div> </td>
</td> <td className="px-4 py-3 align-top">
<td className="px-4 py-3 align-top"> <Badge
<div className="flex h-9 items-center"> variant="muted"
<Switch className="h-9 rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs"
checked={ >
claim.readPermission === "user_and_admin" {t(
} "ui.dev.clients.general.id_token_claims.namespace_rp_claims",
onCheckedChange={(checked) => "rp_claims",
setIdTokenClaimPermissionAllowed( )}
</Badge>
</td>
<td className="px-4 py-3 align-top">
<select
value={claim.valueType}
onChange={(e) =>
updateIdTokenClaim(
claim.id, claim.id,
"readPermission", "valueType",
checked, e.target.value as ClaimValueType,
) )
} }
aria-label={t( aria-label={t(
"ui.dev.clients.general.id_token_claims.read_user_allowed_label", "ui.dev.clients.general.id_token_claims.value_type_label",
"Read 사용자 허용", "Claim 값 타입",
)} )}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly} disabled={isGeneralSettingsReadOnly}
/> >
</div> <option value="text">
</td> {t(
<td className="px-4 py-3 align-top"> "ui.dev.clients.general.id_token_claims.value_type_text",
<div className="flex h-9 items-center"> "Text",
<Switch )}
checked={ </option>
claim.writePermission === "user_and_admin" <option value="number">
} {t(
onCheckedChange={(checked) => "ui.dev.clients.general.id_token_claims.value_type_number",
setIdTokenClaimPermissionAllowed( "Number",
claim.id, )}
"writePermission", </option>
checked, <option value="float">
) {t(
} "ui.dev.clients.general.id_token_claims.value_type_float",
aria-label={t( "Float",
"ui.dev.clients.general.id_token_claims.write_user_allowed_label", )}
"Write 사용자 허용", </option>
)} <option value="boolean">
disabled={isGeneralSettingsReadOnly} {t(
/> "ui.dev.clients.general.id_token_claims.value_type_boolean",
</div> "Boolean",
</td> )}
<td className="px-4 py-3 align-top"> </option>
<Input <option value="array">
value={claim.value} {t(
onChange={(e) => "ui.dev.clients.general.id_token_claims.value_type_array",
updateIdTokenClaim( "Array",
claim.id, )}
"value", </option>
e.target.value, <option value="object">
) {t(
} "ui.dev.clients.general.id_token_claims.value_type_object",
className="h-9 font-mono text-xs" "Object",
placeholder={t( )}
"ui.dev.clients.general.id_token_claims.value_placeholder", </option>
"Enter the default value", <option value="date">
{t(
"ui.dev.clients.general.id_token_claims.value_type_date",
"Date",
)}
</option>
<option value="datetime">
{t(
"ui.dev.clients.general.id_token_claims.value_type_datetime",
"Datetime",
)}
</option>
</select>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={claim.nullable}
onCheckedChange={(checked) =>
updateIdTokenClaim(
claim.id,
"nullable",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.nullable_label",
"Nullable",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.readPermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"readPermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.read_user_allowed_label",
"사용자 읽기 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
<div className="flex h-9 items-center">
<Switch
checked={
claim.writePermission === "user_and_admin"
}
onCheckedChange={(checked) =>
setIdTokenClaimPermissionAllowed(
claim.id,
"writePermission",
checked,
)
}
aria-label={t(
"ui.dev.clients.general.id_token_claims.write_user_allowed_label",
"사용자 쓰기 허용",
)}
disabled={isGeneralSettingsReadOnly}
/>
</div>
</td>
<td className="px-4 py-3 align-top">
{claim.valueType === "array" ||
claim.valueType === "object" ? (
<Textarea
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="min-h-9 font-mono text-xs"
placeholder={
claim.valueType === "array"
? `["value"]`
: `{"key": "value"}`
}
disabled={isGeneralSettingsReadOnly}
/>
) : claim.valueType === "boolean" ? (
<select
value={
claim.value === "false" ? "false" : "true"
}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-xs shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isGeneralSettingsReadOnly}
>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<Input
type={claimDefaultInputType(claim.valueType)}
inputMode={claimDefaultInputMode(
claim.valueType,
)}
pattern={claimDefaultInputPattern(
claim.valueType,
)}
value={claim.value}
onChange={(e) =>
updateIdTokenClaim(
claim.id,
"value",
e.target.value,
)
}
className="h-9 font-mono text-xs"
placeholder={t(
"ui.dev.clients.general.id_token_claims.value_placeholder",
"Enter the default value",
)}
disabled={isGeneralSettingsReadOnly}
aria-invalid={
defaultValueError ? true : undefined
}
/>
)} )}
disabled={isGeneralSettingsReadOnly} {defaultValueError && (
/> <p className="mt-1 text-xs text-destructive">
</td> {defaultValueError}
<td className="px-4 py-3 text-right align-top"> </p>
<Button )}
variant="ghost" </td>
size="icon" <td className="px-4 py-3 text-right align-top">
onClick={() => removeIdTokenClaim(claim.id)} <Button
className="h-9 w-9 text-muted-foreground hover:text-destructive" variant="ghost"
disabled={isGeneralSettingsReadOnly} size="icon"
> onClick={() => removeIdTokenClaim(claim.id)}
<Trash2 className="h-4 w-4" /> className="h-9 w-9 text-muted-foreground hover:text-destructive"
</Button> disabled={isGeneralSettingsReadOnly}
</td> >
</tr> <Trash2 className="h-4 w-4" />
))} </Button>
</td>
</tr>
);
})}
{idTokenClaims.length === 0 && ( {idTokenClaims.length === 0 && (
<tr> <tr>
<td <td
@@ -2378,7 +2595,7 @@ function ClientGeneralPage() {
<p className="text-xs leading-6 text-muted-foreground"> <p className="text-xs leading-6 text-muted-foreground">
{t( {t(
"msg.dev.clients.general.id_token_claims.hint", "msg.dev.clients.general.id_token_claims.hint",
"RP 전용 확장 claim 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다.", "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다.",
)} )}
</p> </p>
</div> </div>

View File

@@ -454,13 +454,14 @@ subtitle = "Define the permission scopes this application can request."
tenant = "Tenant access claim" tenant = "Tenant access claim"
[msg.dev.clients.general.id_token_claims] [msg.dev.clients.general.id_token_claims]
subtitle = "Separate shared claims from RP-specific extension claims." subtitle = "Manage RP-specific extension claims separately."
empty = "No ID Token claims have been added yet." empty = "No ID Token claims have been added yet."
hint = "Manage RP-specific extension claims only. Arrays accept JSON or comma-separated values, and objects accept JSON." hint = "Manage RP-specific extension claims separately. Edit per-user claim values in Consents & Claims."
preview_hint = "Preview the metadata.id_token_claims structure that will be saved." preview_hint = "Preview the metadata.id_token_claims structure that will be saved."
key_required = "Enter a claim key." key_required = "Enter a claim key."
reserved_key = "`rp_claims` is a reserved namespace key." reserved_key = "`rp_claims` is a reserved namespace key."
duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}" duplicate_key = "Duplicate claim key: {{namespace}}.{{key}}"
invalid_default_value = "The claim default value does not match its type: {{key}} ({{valueType}})"
[msg.dev.clients.general.security] [msg.dev.clients.general.security]
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers." private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
@@ -1537,10 +1538,10 @@ title = "Security Note"
[ui.dev.clients.details.tab] [ui.dev.clients.details.tab]
connection = "Federation" connection = "Federation"
consents = "Consent & Users" consents = "Consents & Claims"
settings = "Settings" settings = "Settings"
relationships = "Relationships" relationships = "Relationships"
user_claims = "User Claims" user_claims = "Consents & Claims"
[ui.dev.clients.general] [ui.dev.clients.general]
create = "Create Application" create = "Create Application"
@@ -1618,19 +1619,20 @@ namespace_label = "Claim namespace"
namespace_top_level = "top-level" namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims" namespace_rp_claims = "rp_claims"
nullable_label = "Nullable" nullable_label = "Nullable"
read_user_allowed_label = "Read user allowed" read_user_allowed_label = "Allow user read"
write_user_allowed_label = "Write user allowed" write_user_allowed_label = "Allow user write"
table.key = "Claim Key" table.key = "Claim Key"
table.namespace = "Namespace" table.namespace = "Namespace"
table.value_type = "Value Type" table.value_type = "Value Type"
table.nullable = "Nullable" table.nullable = "Nullable"
table.read_user_allowed = "Read" table.read_user_allowed = "User read"
table.write_user_allowed = "Write" table.write_user_allowed = "User write"
table.default_value = "Default Value" table.default_value = "Default Value"
table.delete = "Delete" table.delete = "Delete"
value_type_label = "Claim value type" value_type_label = "Claim value type"
value_type_text = "Text" value_type_text = "Text"
value_type_number = "Number" value_type_number = "Number"
value_type_float = "Float"
value_type_boolean = "Boolean" value_type_boolean = "Boolean"
value_type_array = "Array" value_type_array = "Array"
value_type_object = "Object" value_type_object = "Object"

View File

@@ -454,13 +454,14 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
tenant = "소속 테넌트 정보 접근" tenant = "소속 테넌트 정보 접근"
[msg.dev.clients.general.id_token_claims] [msg.dev.clients.general.id_token_claims]
subtitle = "공통 claim과 RP 전용 확장 claim을 구분해서 관리합니다." subtitle = "RP 전용 확장 claim을 구분해서 관리합니다."
empty = "아직 추가된 ID Token claim이 없습니다." empty = "아직 추가된 ID Token claim이 없습니다."
hint = "RP 전용 확장 claim 관리합니다. 배열은 JSON 또는 콤마 구분 문자열, 객체는 JSON을 입력하면 됩니다." hint = "RP 전용 확장 claim을 구분해서 관리합니다. 사용자별 claim 값은 동의 및 Claims 탭에서 수정합니다."
preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다." preview_hint = "저장될 metadata.id_token_claims 구조를 미리 확인할 수 있습니다."
key_required = "Claim key를 입력해야 합니다." key_required = "Claim key를 입력해야 합니다."
reserved_key = "`rp_claims`는 예약된 namespace 키입니다." reserved_key = "`rp_claims`는 예약된 namespace 키입니다."
duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}" duplicate_key = "중복된 claim key가 있습니다: {{namespace}}.{{key}}"
invalid_default_value = "Claim 기본값이 타입과 맞지 않습니다: {{key}} ({{valueType}})"
[msg.dev.clients.general.security] [msg.dev.clients.general.security]
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
@@ -1536,10 +1537,10 @@ title = "보안 메모"
[ui.dev.clients.details.tab] [ui.dev.clients.details.tab]
connection = "연동 설정" connection = "연동 설정"
consents = "동의 및 사용자" consents = "동의 및 Claims"
settings = "설정" settings = "설정"
relationships = "관계" relationships = "관계"
user_claims = "사용자 Claim" user_claims = "Consents & Claims"
[ui.dev.clients.general] [ui.dev.clients.general]
create = "앱 생성" create = "앱 생성"
@@ -1616,20 +1617,21 @@ preview_title = "저장 JSON 미리보기"
namespace_label = "Claim 네임스페이스" namespace_label = "Claim 네임스페이스"
namespace_top_level = "top-level" namespace_top_level = "top-level"
namespace_rp_claims = "rp_claims" namespace_rp_claims = "rp_claims"
nullable_label = "Null 허용" nullable_label = "Nullable"
read_user_allowed_label = "Read 사용자 허용" read_user_allowed_label = "사용자 읽기 허용"
write_user_allowed_label = "Write 사용자 허용" write_user_allowed_label = "사용자 쓰기 허용"
table.key = "Claim Key" table.key = "Claim Key"
table.namespace = "Namespace" table.namespace = "Namespace"
table.value_type = "Value Type" table.value_type = "Value Type"
table.nullable = "Null 허용" table.nullable = "Nullable"
table.read_user_allowed = "Read" table.read_user_allowed = "사용자 읽기"
table.write_user_allowed = "Write" table.write_user_allowed = "사용자 쓰기"
table.default_value = "기본값" table.default_value = "기본값"
table.delete = "삭제" table.delete = "삭제"
value_type_label = "Claim 값 타입" value_type_label = "Claim 값 타입"
value_type_text = "텍스트" value_type_text = "텍스트"
value_type_number = "숫자" value_type_number = "숫자"
value_type_float = "실수"
value_type_boolean = "불리언" value_type_boolean = "불리언"
value_type_array = "배열" value_type_array = "배열"
value_type_object = "객체" value_type_object = "객체"

View File

@@ -445,6 +445,7 @@ preview_hint = ""
key_required = "" key_required = ""
reserved_key = "" reserved_key = ""
duplicate_key = "" duplicate_key = ""
invalid_default_value = ""
[msg.dev.clients.relationships] [msg.dev.clients.relationships]
subtitle = "" subtitle = ""
@@ -1679,6 +1680,7 @@ table.delete = ""
value_type_label = "" value_type_label = ""
value_type_text = "" value_type_text = ""
value_type_number = "" value_type_number = ""
value_type_float = ""
value_type_boolean = "" value_type_boolean = ""
value_type_array = "" value_type_array = ""
value_type_object = "" value_type_object = ""

View File

@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { import {
type ClientRelation,
type Consent, type Consent,
installDevApiMock, installDevApiMock,
makeClient, makeClient,
@@ -7,6 +8,39 @@ import {
} from "./helpers/devfront-fixtures"; } from "./helpers/devfront-fixtures";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront"; import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
const editRelations = [
{
relation: "config_editor",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
},
{
relation: "admins",
subject: "User:playwright-user",
subjectType: "User",
subjectId: "playwright-user",
},
{
relation: "config_editor",
subject: "User:admin-user",
subjectType: "User",
subjectId: "admin-user",
},
{
relation: "config_editor",
subject: "User:undefined",
subjectType: "User",
subjectId: "undefined",
},
{
relation: "config_editor",
subject: "User:",
subjectType: "User",
subjectId: "",
},
] satisfies ClientRelation[];
test.describe("DevFront RP claim cache", () => { test.describe("DevFront RP claim cache", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await installDevFrontStaticRoutes(page); await installDevFrontStaticRoutes(page);
@@ -33,6 +67,9 @@ test.describe("DevFront RP claim cache", () => {
}), }),
], ],
consents: [] as Consent[], consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined, auditLogsByCursor: undefined,
mockRole: "super_admin", mockRole: "super_admin",
}; };
@@ -44,6 +81,7 @@ test.describe("DevFront RP claim cache", () => {
.getByPlaceholder(/e\.g\. locale|예: locale/i) .getByPlaceholder(/e\.g\. locale|예: locale/i)
.first(); .first();
await expect(claimKeyInput).toHaveValue("old_claim"); await expect(claimKeyInput).toHaveValue("old_claim");
await expect(claimKeyInput).toBeEnabled();
await claimKeyInput.fill("new_claim"); await claimKeyInput.fill("new_claim");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
@@ -60,4 +98,208 @@ test.describe("DevFront RP claim cache", () => {
.toBe("new_claim"); .toBe("new_claim");
await expect(claimKeyInput).toHaveValue("new_claim"); await expect(claimKeyInput).toHaveValue("new_claim");
}); });
test("forces read permission on when write permission is enabled", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "locale",
value: "ko",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
const readSwitch = page
.getByRole("switch", { name: /사용자 읽기|Allow user read/i })
.first();
const writeSwitch = page
.getByRole("switch", { name: /사용자 쓰기|Allow user write/i })
.first();
await expect(readSwitch).toHaveAttribute("aria-checked", "false");
await expect(writeSwitch).toHaveAttribute("aria-checked", "false");
await expect(readSwitch).toBeEnabled();
await expect(writeSwitch).toBeEnabled();
await writeSwitch.click();
await expect(readSwitch).toHaveAttribute("aria-checked", "true");
await expect(writeSwitch).toHaveAttribute("aria-checked", "true");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{
readPermission?: string;
writePermission?: string;
}>
| undefined
)?.[0],
)
.toMatchObject({
readPermission: "user_and_admin",
writePermission: "user_and_admin",
});
});
test("blocks saving an RP claim default value that does not match the selected value type", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "profile",
value: "{}",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
await page
.getByLabel(/Claim 값 타입|Claim value type/i)
.first()
.selectOption("object");
await page
.locator('textarea[placeholder="{\\"key\\": \\"value\\"}"]')
.fill("not-json");
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
expect(
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{ valueType?: string; value?: string }>
| undefined
)?.[0],
).toMatchObject({
value: "{}",
valueType: "text",
});
});
test("saves a float RP claim default value and blocks decimal values for integer number claims", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-claims", {
name: "Claims app",
metadata: {
id_token_claims: [
{
namespace: "rp_claims",
key: "ratio",
value: "0",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
}),
],
consents: [] as Consent[],
relations: {
"client-claims": editRelations,
},
auditLogsByCursor: undefined,
mockRole: "super_admin",
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-claims/settings");
await page
.getByLabel(/Claim 값 타입|Claim value type/i)
.first()
.selectOption("float");
await page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("3.14");
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
await expect
.poll(
() =>
(
state.clients[0]?.metadata?.id_token_claims as
| Array<{ valueType?: string; value?: string }>
| undefined
)?.[0],
)
.toMatchObject({
value: "3.14",
valueType: "float",
});
const valueTypeSelect = page
.getByLabel(/Claim 값 타입|Claim value type/i)
.first();
await expect(valueTypeSelect).toHaveValue("float");
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeEnabled();
await valueTypeSelect.selectOption("number");
await expect(valueTypeSelect).toHaveValue("number");
await page
.getByPlaceholder(/기본값을 입력하세요|Enter the default value/i)
.first()
.fill("3.14");
await expect(
page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i),
).toBeVisible();
await expect(
page.getByRole("button", { name: /^저장$|^Save$/i }),
).toBeDisabled();
});
}); });

View File

@@ -6,6 +6,7 @@ import {
makeClient, makeClient,
seedAuth, seedAuth,
} from "./helpers/devfront-fixtures"; } from "./helpers/devfront-fixtures";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) { function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
return async ({ page }: { page: Page }) => { return async ({ page }: { page: Page }) => {
@@ -24,9 +25,10 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
}, },
auditLogsByCursor: undefined, auditLogsByCursor: undefined,
}; };
await installDevFrontStaticRoutes(page);
await installDevApiMock(page, state); await installDevApiMock(page, state);
await page.goto(pagePath); await page.goto(`http://devfront.test${pagePath}`);
const header = page const header = page
.locator("header") .locator("header")
@@ -38,7 +40,7 @@ function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) {
await expect(tabs).toHaveText([ await expect(tabs).toHaveText([
"연동 설정", "연동 설정",
"사용자 Claim", "동의 및 Claims",
"설정", "설정",
"관계", "관계",
]); ]);

View File

@@ -6,6 +6,7 @@ import {
seedAuth, seedAuth,
} from "./helpers/devfront-fixtures"; } from "./helpers/devfront-fixtures";
import { captureEvidence } from "./helpers/evidence"; import { captureEvidence } from "./helpers/evidence";
import { installDevFrontStaticRoutes } from "./helpers/static-devfront";
test.describe("DevFront consents", () => { test.describe("DevFront consents", () => {
test.afterEach(async ({ page }, testInfo) => { test.afterEach(async ({ page }, testInfo) => {
@@ -15,6 +16,7 @@ test.describe("DevFront consents", () => {
}); });
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await installDevFrontStaticRoutes(page);
page.on("dialog", async (dialog) => { page.on("dialog", async (dialog) => {
await dialog.accept(); await dialog.accept();
}); });
@@ -81,7 +83,7 @@ test.describe("DevFront consents", () => {
}; };
await installDevApiMock(page, state); await installDevApiMock(page, state);
await page.goto("/clients/client-consent/consents"); await page.goto("http://devfront.test/clients/client-consent/consents");
await expect(page.getByText("Alice")).toBeVisible(); await expect(page.getByText("Alice")).toBeVisible();
await expect(page.getByText("Tenant A")).toBeVisible(); await expect(page.getByText("Tenant A")).toBeVisible();
await expect(page.getByText(/approvalLevel:\s*A/)).toBeVisible(); await expect(page.getByText(/approvalLevel:\s*A/)).toBeVisible();
@@ -127,4 +129,43 @@ test.describe("DevFront consents", () => {
await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click(); await page.getByRole("button", { name: /권한 철회|철회|Revoke/i }).click();
await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible(); await expect(page.getByText(/Revoked|철회/i).first()).toBeVisible();
}); });
test("does not allow adding undefined RP claims from consents and claims", async ({
page,
}) => {
const state = {
clients: [
makeClient("client-consent", {
name: "Consent app",
metadata: {},
}),
],
consents: [
{
subject: "user-1",
userName: "Alice",
clientId: "client-consent",
clientName: "Consent app",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-03-03T08:00:00.000Z",
createdAt: "2026-03-02T08:00:00.000Z",
status: "active",
tenantId: "tenant-a",
tenantName: "Tenant A",
rpMetadata: {},
},
] as Consent[],
auditLogsByCursor: undefined,
};
await installDevApiMock(page, state);
await page.goto("http://devfront.test/clients/client-consent/consents");
await page.getByRole("button", { name: /Claims|Claim/i }).click();
await expect(page.getByText("RP Custom Claims")).toBeVisible();
await expect(
page.getByRole("button", { name: /^추가$|^Add$/ }),
).toHaveCount(0);
await expect(page.getByPlaceholder(/claim_key/i)).toHaveCount(0);
});
}); });

View File

@@ -19,7 +19,20 @@ Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터
- **Ory Keto (Relationship SSOT)**: 테넌트 소속, 소유, 접근 같은 권한 관계를 저장하고 판정합니다. - **Ory Keto (Relationship SSOT)**: 테넌트 소속, 소유, 접근 같은 권한 관계를 저장하고 판정합니다.
- **Backend DB read model**: Ory에 저장되지 않거나 조회가 불가능한 테넌트 표시/검색 metadata, 설정, 외부 연동 상태만 저장합니다. - **Backend DB read model**: Ory에 저장되지 않거나 조회가 불가능한 테넌트 표시/검색 metadata, 설정, 외부 연동 상태만 저장합니다.
## 3. 데이터베이스 스키마 분리 전략 ## 3. Seed tenant 식별 정책
`adminfront/seed-tenant.csv`에 정의된 초기 테넌트는 CSV의 `id`/`tenant_id` UUID를 source of truth로 삼습니다. `slug`는 운영자가 읽고 외부 연동에서 다루기 쉬운 식별자지만, 오타 수정이나 명칭 정책 변경으로 바뀔 수 있으므로 초기 테넌트 보호 여부와 seed 동기화의 최종 식별 기준으로 사용하지 않습니다.
- 초기 테넌트 여부는 seed CSV의 UUID와 `tenants.id` 일치 여부로 판단합니다.
- `slug`, `name`, `memo`, 도메인 같은 표시/설정 값은 UUID로 식별된 seed tenant의 동기화 대상 metadata입니다.
- 기존 DB row가 seed UUID와 일치하지만 `slug`가 CSV와 다르면, backend bootstrap seed 경로는 CSV의 `slug`로 보정해야 합니다.
- 목표 `slug`를 다른 활성 tenant가 이미 사용 중이면 자동 보정하지 않고 충돌로 처리합니다.
- AdminFront의 “초기 설정” 표시와 삭제 보호도 `slug`가 아니라 seed UUID 기준으로 동작해야 합니다.
- 일반 import/export 정책에서는 운영 편의를 위해 `slug`를 우선 사용할 수 있지만, seed tenant의 identity 보존 및 복구 정책은 UUID 기준을 우선합니다.
예를 들어 한라산업개발 seed row의 UUID가 `5a03efd2-e62f-4243-800d-58334bf48b2f`이면, 기존 DB의 `slug``hanlla`여도 같은 UUID row는 동일 seed tenant로 보고 CSV 값인 `halla`로 보정합니다.
## 4. 데이터베이스 스키마 분리 전략
테넌트 테이블의 비대화를 막기 위해, Identity(신분증) 역할과 무거운 Business 데이터를 분리 조인(Join)합니다. 테넌트 테이블의 비대화를 막기 위해, Identity(신분증) 역할과 무거운 Business 데이터를 분리 조인(Join)합니다.
@@ -27,7 +40,7 @@ Kratos 내부 트레이트(Traits)에 테넌트, 직급 등 관계형 데이터
- **`company_settings` 테이블**: `COMPANY``COMPANY_GROUP` 타입 전용 무거운 비즈니스 설정 (결제 정보, 커스텀 도메인 등). - **`company_settings` 테이블**: `COMPANY``COMPANY_GROUP` 타입 전용 무거운 비즈니스 설정 (결제 정보, 커스텀 도메인 등).
- **`user_groups` 테이블**: `USER_GROUP` 타입 전용 사내 조직도 메타데이터 (`parent_id`, 조직장 정보 등). - **`user_groups` 테이블**: `USER_GROUP` 타입 전용 사내 조직도 메타데이터 (`parent_id`, 조직장 정보 등).
## 4. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling) ## 5. 논리적 다중 테넌트 OIDC 관리 (Logical Pooling)
인프라 비용의 팽창을 막기 위해 테넌트별로 Hydra(OAuth2) 데이터베이스를 물리적으로 복제하는 방식은 금지합니다. 인프라 비용의 팽창을 막기 위해 테넌트별로 Hydra(OAuth2) 데이터베이스를 물리적으로 복제하는 방식은 금지합니다.
대신 공유되는 소수의 Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시를 배치하고, 백엔드의 동의(Consent) 로직을 통해 요청된 클라이언트의 테넌트 맥락에 맞는 **동적 클레임(Dynamic Claim)**을 ID Token에 주입합니다. 대신 공유되는 소수의 Hydra 클러스터 앞단에 도메인 및 헤더를 재작성하는 지능형 프록시를 배치하고, 백엔드의 동의(Consent) 로직을 통해 요청된 클라이언트의 테넌트 맥락에 맞는 **동적 클레임(Dynamic Claim)**을 ID Token에 주입합니다.

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WORKFLOW_FILE="$ROOT_DIR/.gitea/workflows/code_check.yml"
fail() {
echo "ERROR: $*" >&2
exit 1
}
job_block() {
local job="$1"
awk -v job=" ${job}:" '
$0 == job { in_job = 1; print; next }
in_job && /^ [a-zA-Z0-9_-]+:/ { exit }
in_job { print }
' "$WORKFLOW_FILE"
}
lint_block="$(job_block lint)"
biome_block="$(job_block biome-check)"
if printf '%s\n' "$lint_block" | grep -Eq 'Biome check (adminfront|devfront|orgfront)|npx biome check'; then
fail "lint job must not duplicate frontend Biome checks; keep them in biome-check"
fi
for app in adminfront devfront orgfront; do
printf '%s\n' "$biome_block" | grep -Fq "Install ${app} dependencies" ||
fail "biome-check job must install ${app} dependencies"
printf '%s\n' "$biome_block" | grep -Fq "Biome check ${app}" ||
fail "biome-check job must check ${app}"
done
for job in \
adminfront-vitest-coverage \
devfront-vitest-coverage \
orgfront-vitest-coverage \
adminfront-tests \
devfront-tests \
orgfront-tests; do
block="$(job_block "$job")"
printf '%s\n' "$block" | grep -Fq " - biome-check" ||
fail "${job} must depend on biome-check instead of duplicating/depending on lint for frontend quality gate"
done
echo "OK: Code Check runs frontend Biome only in the biome-check job"

604
test/rp_claims_live_e2e.mjs Normal file
View File

@@ -0,0 +1,604 @@
import { mkdir, writeFile } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import playwright from "../devfront/node_modules/playwright/index.js";
const { chromium } = playwright;
const gatewayUrl = process.env.RP_CLAIMS_E2E_GATEWAY_URL ?? "http://localhost:5000";
const devfrontUrl = process.env.RP_CLAIMS_E2E_DEVFRONT_URL ?? "http://localhost:5174";
const reportDir = process.env.RP_CLAIMS_E2E_REPORT_DIR ?? "reports/rp-claims-live-e2e";
const tenantSlug = process.env.RP_CLAIMS_E2E_TENANT_SLUG ?? "public-org";
const runId = process.env.RP_CLAIMS_E2E_RUN_ID ?? `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const email = `rp-claims-${runId}@example.test`;
const loginId = `rp${runId.replaceAll("-", "").slice(0, 18)}`;
const loginIdentifier = email;
const password = `RpClaims${runId.slice(0, 6)}!1aA`;
const redirectUri = "http://127.0.0.1:18080/callback";
const subjectByLoginId = new Map();
const scenarios = [
{
name: "rp-a",
clientId: `rp-claims-a-${runId}`,
defaults: {
approvalLevel: "A",
activeMember: true,
score: 1,
featureList: ["default"],
preferences: { theme: "light", density: "comfortable" },
contractDate: "2026-06-09",
approvedAt: "2026-06-09T09:30",
adminManagedNote: "admin-default",
},
updates: [
{
label: "admin-update",
metadata: {
approvalLevel: "B",
activeMember: false,
score: 42,
featureList: ["sso", "claims"],
preferences: { theme: "dark", density: "compact" },
contractDate: "2026-06-10",
approvedAt: "2026-06-09T10:30",
adminManagedNote: "admin-updated",
approvalLevel_permissions: {
readPermission: "admin_only",
writePermission: "user_and_admin",
},
},
expected: {
approvalLevel: "B",
activeMember: false,
score: 42,
featureList: ["sso", "claims"],
preferences: { theme: "dark", density: "compact" },
contractDate: "2026-06-10",
approvedAt: "2026-06-09T10:30",
adminManagedNote: "admin-updated",
},
},
{
label: "second-update",
metadata: {
approvalLevel: "C",
activeMember: true,
score: 7.5,
featureList: ["delta", "omega", "42"],
preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
contractDate: "2026-06-11",
approvedAt: "2026-06-11T14:45:30Z",
adminManagedNote: "admin-updated-again",
approvalLevel_permissions: {
readPermission: "user_and_admin",
writePermission: "user_and_admin",
},
},
expected: {
approvalLevel: "C",
activeMember: true,
score: 7.5,
featureList: ["delta", "omega", "42"],
preferences: { theme: "contrast", density: "spacious", nested: { level: 2 } },
contractDate: "2026-06-11",
approvedAt: "2026-06-11T14:45:30Z",
adminManagedNote: "admin-updated-again",
},
},
],
},
{
name: "rp-b",
clientId: `rp-claims-b-${runId}`,
defaults: {
approvalLevel: "B-default",
activeMember: false,
score: 100,
featureList: ["rp-b-default"],
preferences: { theme: "blue", density: "wide" },
contractDate: "2026-07-01",
approvedAt: "2026-07-01T08:00",
},
updates: [
{
label: "rp-b-isolated-update",
metadata: {
approvalLevel: "B-only",
activeMember: true,
score: 314,
featureList: ["only", "rp-b"],
preferences: { theme: "green", density: "narrow" },
contractDate: "2026-07-02",
approvedAt: "2026-07-02T09:15",
},
expected: {
approvalLevel: "B-only",
activeMember: true,
score: 314,
featureList: ["only", "rp-b"],
preferences: { theme: "green", density: "narrow" },
contractDate: "2026-07-02",
approvedAt: "2026-07-02T09:15",
},
},
],
},
];
const report = {
runId,
gatewayUrl,
devfrontUrl,
loginId,
loginIdentifier,
email,
createdAt: new Date().toISOString(),
steps: [],
scenarios: [],
passed: false,
};
function assertDeepEqual(actual, expected, label) {
const actualJson = JSON.stringify(canonicalize(actual));
const expectedJson = JSON.stringify(canonicalize(expected));
if (actualJson !== expectedJson) {
throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
}
}
function canonicalize(value) {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)]),
);
}
return value;
}
function assertAbsent(record, key, label) {
if (Object.hasOwn(record ?? {}, key)) {
throw new Error(`${label}: ${key} must not be present`);
}
}
function base64urlDecode(input) {
const padded = input.padEnd(input.length + ((4 - (input.length % 4)) % 4), "=");
return Buffer.from(padded.replaceAll("-", "+").replaceAll("_", "/"), "base64").toString("utf8");
}
function decodeJwtPayload(token) {
const parts = String(token).split(".");
if (parts.length < 2) {
throw new Error("id_token is not a JWT");
}
return JSON.parse(base64urlDecode(parts[1]));
}
function base64url(buffer) {
return Buffer.from(buffer)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
function pkceChallenge(verifier) {
return base64url(createHash("sha256").update(verifier).digest());
}
function claimDefinitions(defaults) {
const claims = [
{ key: "approvalLevel", valueType: "text", value: defaults.approvalLevel },
{ key: "activeMember", valueType: "boolean", value: String(defaults.activeMember) },
{ key: "score", valueType: "number", value: String(defaults.score) },
{ key: "featureList", valueType: "array", value: JSON.stringify(defaults.featureList) },
{ key: "preferences", valueType: "object", value: JSON.stringify(defaults.preferences) },
{ key: "contractDate", valueType: "date", value: defaults.contractDate },
{ key: "approvedAt", valueType: "datetime", value: defaults.approvedAt },
].filter((claim) => claim.value !== undefined);
if (defaults.adminManagedNote !== undefined) {
claims.push({
key: "adminManagedNote",
valueType: "text",
value: defaults.adminManagedNote,
writePermission: "admin_only",
});
}
return claims.map((claim) => ({
namespace: "rp_claims",
readPermission: "user_and_admin",
writePermission: claim.writePermission ?? "user_and_admin",
...claim,
}));
}
async function requestJson(url, options = {}) {
const { cookieJar, ...fetchOptions } = options;
const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
const response = await fetch(url, {
...fetchOptions,
headers: {
"Content-Type": "application/json",
"X-Test-Role": "super_admin",
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(options.headers ?? {}),
},
});
rememberCookies(cookieJar, response);
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!response.ok) {
throw new Error(`${options.method ?? "GET"} ${url} failed: ${response.status} ${text}`);
}
return { response, body };
}
async function createUser() {
const body = {
email,
loginId,
password,
name: `RP Claims ${runId}`,
role: "user",
tenantSlug,
};
const result = await requestJson(`${gatewayUrl}/api/v1/admin/users`, {
method: "POST",
body: JSON.stringify(body),
});
const id = result.body?.id ?? result.body?.user?.id ?? result.body?.identityId;
if (!id) {
throw new Error(`created user response has no id: ${JSON.stringify(result.body)}`);
}
subjectByLoginId.set(loginIdentifier, id);
report.steps.push({ step: "create-user", status: "ok", subject: id });
return id;
}
async function createClient(scenario) {
const body = {
id: scenario.clientId,
name: `RP Claims E2E ${scenario.name} ${runId}`,
type: "pkce",
status: "active",
redirectUris: [redirectUri],
scopes: ["openid", "profile", "email"],
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
tokenEndpointAuthMethod: "none",
skipConsent: false,
metadata: {
id_token_claims: claimDefinitions(scenario.defaults),
tenant_id: "tenant-rp-claims-live-e2e",
},
};
await requestJson(`${devfrontUrl}/api/v1/dev/clients`, {
method: "POST",
body: JSON.stringify(body),
});
report.steps.push({ step: "create-client", status: "ok", clientId: scenario.clientId });
}
function cookieHeaderFromJar(cookieJar) {
return Object.entries(cookieJar ?? {})
.map(([name, value]) => `${name}=${value}`)
.join("; ");
}
function rememberCookies(cookieJar, response) {
if (!cookieJar || !response?.headers) {
return;
}
const setCookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: response.headers.get("set-cookie")
? [response.headers.get("set-cookie")]
: [];
for (const header of setCookies) {
const firstPart = String(header ?? "").split(";")[0];
const separator = firstPart.indexOf("=");
if (separator <= 0) {
continue;
}
cookieJar[firstPart.slice(0, separator)] = firstPart.slice(separator + 1);
}
}
async function manualFetch(url, options = {}) {
try {
const { cookieJar, ...fetchOptions } = options;
const cookieHeader = cookieJar ? cookieHeaderFromJar(cookieJar) : "";
const response = await fetch(url, {
redirect: "manual",
...fetchOptions,
headers: {
...(cookieHeader ? { Cookie: cookieHeader } : {}),
...(options.headers ?? {}),
},
});
rememberCookies(cookieJar, response);
return response;
} catch (error) {
throw new Error(`manual fetch failed for ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
}
function locationFrom(response, label) {
const location = response.headers.get("location");
if (!location) {
throw new Error(`${label}: redirect location missing, status=${response.status}`);
}
return new URL(location, gatewayUrl).toString();
}
function searchParam(url, name, label) {
const value = new URL(url).searchParams.get(name);
if (!value) {
throw new Error(`${label}: ${name} missing from ${url}`);
}
return value;
}
async function authorizeAndDecodeClaims(clientId, label) {
const state = `${label}-${Math.random().toString(16).slice(2, 8)}`;
const nonce = `${label}-${Math.random().toString(16).slice(2, 8)}`;
const verifier = base64url(randomBytes(32));
const cookieJar = {};
const authorize = new URL(`${gatewayUrl}/oidc/oauth2/auth`);
authorize.searchParams.set("client_id", clientId);
authorize.searchParams.set("redirect_uri", redirectUri);
authorize.searchParams.set("response_type", "code");
authorize.searchParams.set("scope", "openid profile email");
authorize.searchParams.set("state", state);
authorize.searchParams.set("nonce", nonce);
authorize.searchParams.set("code_challenge", pkceChallenge(verifier));
authorize.searchParams.set("code_challenge_method", "S256");
const trace = { label, clientId, authorize: authorize.toString(), redirects: [] };
report.steps.push({ step: "authorize-token", status: "started", trace });
const first = await manualFetch(authorize, { cookieJar });
let next = locationFrom(first, `${label}: authorize`);
trace.redirects.push({ from: "authorize", status: first.status, location: next });
const loginChallenge = searchParam(next, "login_challenge", `${label}: login redirect`);
const login = await requestJson(`${gatewayUrl}/api/v1/auth/password/login`, {
method: "POST",
cookieJar,
body: JSON.stringify({ loginId: loginIdentifier, password, login_challenge: loginChallenge }),
});
next = login.body.redirectTo;
if (!next) {
throw new Error(`${label}: password login response missing redirectTo`);
}
trace.redirects.push({ from: "password-login", location: next });
let afterLogin = await manualFetch(next, { cookieJar });
next = locationFrom(afterLogin, `${label}: after login`);
trace.redirects.push({ from: "after-login", status: afterLogin.status, location: next });
if (new URL(next).searchParams.has("consent_challenge")) {
const consentChallenge = searchParam(next, "consent_challenge", `${label}: consent redirect`);
const consent = await requestJson(`${gatewayUrl}/api/v1/auth/consent/accept`, {
method: "POST",
cookieJar,
body: JSON.stringify({
consent_challenge: consentChallenge,
grant_scope: ["openid", "profile", "email"],
}),
});
next = consent.body.redirectTo;
if (!next) {
throw new Error(`${label}: consent response missing redirectTo`);
}
trace.redirects.push({ from: "consent-accept", location: next });
afterLogin = await manualFetch(next, { cookieJar });
next = locationFrom(afterLogin, `${label}: after consent`);
trace.redirects.push({ from: "after-consent", status: afterLogin.status, location: next });
}
if (new URL(next).searchParams.has("error")) {
const callback = new URL(next);
throw new Error(`${label}: callback error ${callback.searchParams.get("error")} ${callback.searchParams.get("error_description") ?? ""}`.trim());
}
if (!new URL(next).searchParams.has("code")) {
if (new URL(next).hostname === "127.0.0.1") {
throw new Error(`${label}: callback has no code: ${next}`);
}
const final = await manualFetch(next, { cookieJar });
next = locationFrom(final, `${label}: final code redirect`);
trace.redirects.push({ from: "final", status: final.status, location: next });
}
const code = searchParam(next, "code", `${label}: callback`);
const returnedState = searchParam(next, "state", `${label}: callback`);
if (returnedState !== state) {
throw new Error(`${label}: state mismatch`);
}
const tokenResponse = await fetch(`${gatewayUrl}/oidc/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId,
redirect_uri: redirectUri,
code,
code_verifier: verifier,
}),
});
const tokenText = await tokenResponse.text();
let tokenBody;
try {
tokenBody = JSON.parse(tokenText);
} catch {
tokenBody = tokenText;
}
if (!tokenResponse.ok) {
throw new Error(`${label}: token failed ${tokenResponse.status} ${tokenText}`);
}
const payload = decodeJwtPayload(tokenBody.id_token);
report.steps.push({ step: "authorize-token", status: "ok", trace });
return {
idTokenPayload: payload,
rpClaims: payload.rp_claims,
tokenResponse: {
token_type: tokenBody.token_type,
expires_in: tokenBody.expires_in,
scope: tokenBody.scope,
has_access_token: Boolean(tokenBody.access_token),
has_id_token: Boolean(tokenBody.id_token),
},
};
}
async function updateClaimsFromDevfrontBrowser(page, scenario, metadata, label) {
const result = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata);
if (!result.ok) {
throw new Error(`${label}: devfront metadata update failed ${result.status} ${JSON.stringify(result.body)}`);
}
return result;
}
async function requestClaimsUpdateFromDevfrontBrowser(page, scenario, metadata) {
const subject = subjectByLoginId.get(loginIdentifier);
return page.evaluate(
async ({ clientId, subject, metadata }) => {
const response = await fetch(`/api/v1/dev/clients/${clientId}/users/${subject}/metadata`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"X-Test-Role": "super_admin",
"X-Tenant-ID": "tenant-rp-claims-live-e2e",
},
body: JSON.stringify({ metadata }),
});
const text = await response.text();
let body;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { ok: response.ok, status: response.status, body };
},
{ clientId: scenario.clientId, subject, metadata },
);
}
let browser;
try {
await mkdir(reportDir, { recursive: true });
const subject = await createUser();
report.subject = subject;
for (const scenario of scenarios) {
await createClient(scenario);
}
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({
extraHTTPHeaders: {
"X-Test-Role": "super_admin",
"X-Tenant-ID": "tenant-rp-claims-live-e2e",
},
});
await page.goto(devfrontUrl, { waitUntil: "domcontentloaded" });
for (const scenario of scenarios) {
const scenarioReport = {
name: scenario.name,
clientId: scenario.clientId,
defaultCheck: null,
negativeChecks: [],
updates: [],
};
const defaults = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-default`);
assertDeepEqual(defaults.rpClaims, scenario.defaults, `${scenario.name}: default rp_claims`);
scenarioReport.defaultCheck = {
expected: scenario.defaults,
actual: defaults.rpClaims,
token: defaults.tokenResponse,
};
const undefinedClaimResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
internalMemo: "must-be-rejected",
});
if (undefinedClaimResult.status !== 400) {
throw new Error(`${scenario.name}: undefined claim update should be rejected with 400, got ${undefinedClaimResult.status}`);
}
scenarioReport.negativeChecks.push({
label: "undefined-claim-rejected",
status: undefinedClaimResult.status,
body: undefinedClaimResult.body,
});
if (Object.hasOwn(scenario.defaults, "score")) {
const invalidTypeResult = await requestClaimsUpdateFromDevfrontBrowser(page, scenario, {
score: "not-a-number",
});
if (invalidTypeResult.status !== 400) {
throw new Error(`${scenario.name}: invalid number update should be rejected with 400, got ${invalidTypeResult.status}`);
}
scenarioReport.negativeChecks.push({
label: "invalid-number-rejected",
status: invalidTypeResult.status,
body: invalidTypeResult.body,
});
}
for (const update of scenario.updates) {
const updateResult = await updateClaimsFromDevfrontBrowser(page, scenario, update.metadata, `${scenario.name}-${update.label}`);
const decoded = await authorizeAndDecodeClaims(scenario.clientId, `${scenario.name}-${update.label}`);
assertDeepEqual(decoded.rpClaims, update.expected, `${scenario.name}.${update.label}: updated rp_claims`);
assertAbsent(decoded.rpClaims, "internalMemo", `${scenario.name}.${update.label}`);
assertAbsent(decoded.rpClaims, "approvalLevel_permissions", `${scenario.name}.${update.label}`);
assertAbsent(decoded.rpClaims, "adminManagedNote_permissions", `${scenario.name}.${update.label}`);
scenarioReport.updates.push({
label: update.label,
devfrontUpdateStatus: updateResult.status,
expected: update.expected,
actual: decoded.rpClaims,
token: decoded.tokenResponse,
});
}
report.scenarios.push(scenarioReport);
}
const rpAAfter = report.scenarios.find((item) => item.name === "rp-a")?.updates.at(-1)?.actual;
const rpBAfter = report.scenarios.find((item) => item.name === "rp-b")?.updates.at(-1)?.actual;
if (rpAAfter?.approvalLevel === rpBAfter?.approvalLevel) {
throw new Error("rp isolation failed: rp-a and rp-b approvalLevel unexpectedly match");
}
assertAbsent(rpBAfter, "internalMemo", "rp-b isolation");
assertAbsent(rpBAfter, "adminManagedNote", "rp-b isolation");
report.passed = true;
} catch (error) {
report.passed = false;
report.error = {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
};
process.exitCode = 1;
} finally {
if (browser) {
await browser.close();
}
report.finishedAt = new Date().toISOString();
const reportPath = `${reportDir}/rp-claims-live-e2e-${runId}.json`;
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({ passed: report.passed, reportPath, runId }, null, 2));
}

View File

@@ -279,10 +279,16 @@ test.describe("UserFront login performance budget", () => {
const rootIndex = requestedUrls.findIndex( const rootIndex = requestedUrls.findIndex(
(url) => new URL(url).pathname === "/", (url) => new URL(url).pathname === "/",
); );
const signinIndex = requestedUrls.findIndex(
(url) => new URL(url).pathname === "/ko/signin",
);
const bootstrapIndex = requestedUrls.findIndex((url) => const bootstrapIndex = requestedUrls.findIndex((url) =>
new URL(url).pathname.endsWith("/flutter_bootstrap.js"), new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
); );
expect(rootIndex).toBeGreaterThanOrEqual(0); expect(rootIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBe(-1); expect(signinIndex).toBeGreaterThan(rootIndex);
if (bootstrapIndex >= 0) {
expect(bootstrapIndex).toBeGreaterThan(signinIndex);
}
}); });
}); });