forked from baron/baron-sso
custom claim 타입보정 UI. 대표테넌트 노출 보정
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 = "형식이 올바르지 않습니다."
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] 실제 생성된 클레임 출력 (요청사항 확인용)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
328
backend/internal/handler/rp_claims_e2e_test.go
Normal file
328
backend/internal/handler/rp_claims_e2e_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 & Claims");
|
||||||
expect(html).toContain('href="/clients/client-a/consents"');
|
expect(html).toContain('href="/clients/client-a/consents"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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` },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "객체"
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
"설정",
|
"설정",
|
||||||
"관계",
|
"관계",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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에 주입합니다.
|
||||||
|
|||||||
47
test/code_check_biome_dedup_test.sh
Normal file
47
test/code_check_biome_dedup_test.sh
Normal 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
604
test/rp_claims_live_e2e.mjs
Normal 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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user