From 6cdd0fd81e93460c7f62290f978bf95d691a4a64 Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 6 May 2026 10:37:34 +0900 Subject: [PATCH 01/15] =?UTF-8?q?worksmobile=20=EA=B4=80=EB=A6=AC=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B3=B4=EC=99=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.tsx | 4 +- .../tenants/routes/TenantListPage.tsx | 35 +- .../routes/TenantWorksmobilePage.test.ts | 195 +++++ .../tenants/routes/TenantWorksmobilePage.tsx | 689 +++++++++++++----- adminfront/src/lib/adminApi.ts | 2 + adminfront/tests/tenants.spec.ts | 10 + adminfront/tests/worksmobile.spec.ts | 186 ++++- .../service/worksmobile_sync_service.go | 2 + .../service/worksmobile_sync_service_test.go | 20 + ...smobile-directory-sync-technical-review.md | 19 + 10 files changed, 943 insertions(+), 219 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 4f71fe3d..78f97232 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -534,7 +534,7 @@ function AppLayout() { -
+
@@ -730,7 +730,7 @@ function AppLayout() {
-
+
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index e86c2db8..666ffb6f 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -483,10 +483,10 @@ function TenantListPage() {
- +
- + 0 && @@ -498,28 +498,28 @@ function TenantListPage() { } /> - + {t("ui.admin.tenants.table.id", "ID")} - + {t("ui.admin.tenants.table.name", "NAME")} - + {t("ui.admin.tenants.table.type", "TYPE")} - + {t("ui.admin.tenants.table.slug", "SLUG")} - + {t("ui.admin.tenants.table.status", "STATUS")} - + {t("ui.admin.tenants.table.members", "MEMBERS")} - + {t("ui.admin.tenants.table.updated", "UPDATED")} - + {t("ui.admin.tenants.table.actions", "ACTIONS")} @@ -575,21 +575,18 @@ function TenantListPage() { )} - + - {t( - `domain.tenant_type.${tenant.type?.toLowerCase()}`, - tenant.type, - )} + {tenant.type} {tenant.slug} - + - + {tenant.memberCount} - + {tenant.updatedAt ? new Date(tenant.updatedAt).toLocaleString("ko-KR") : "-"} - +
- ))} -
{ + setUserFilters(nextFilters); + setSelectedUserRowKeys([]); + }} + visibleColumns={userVisibleColumns} + onVisibleColumnsChange={setUserVisibleColumns} + passwordManageTenantId={overview?.config.adminTenantId} actionLabel="선택 구성원 WORKS에 생성" actionDisabled={isCreatingUsers || createSelectedMutation.isPending} - onCreateSelected={() => + onCreateSelected={(ids) => createSelectedMutation.mutate({ resourceKind: "users", - ids: selectedUserIds, + ids, }) } /> @@ -320,16 +330,21 @@ export function TenantWorksmobilePage() { )} rows={comparisonGroups} loading={comparisonQuery.isLoading} - selectedIds={selectedGroupIds} - onSelectedIdsChange={setSelectedGroupIds} + selectedKeys={selectedGroupRowKeys} + onSelectedKeysChange={setSelectedGroupRowKeys} + filters={undefined} + onFiltersChange={undefined} + visibleColumns={groupVisibleColumns} + onVisibleColumnsChange={setGroupVisibleColumns} + passwordManageTenantId={undefined} actionLabel="선택 조직 WORKS에 생성" actionDisabled={ isCreatingGroups || createSelectedMutation.isPending } - onCreateSelected={() => + onCreateSelected={(ids) => createSelectedMutation.mutate({ resourceKind: "groups", - ids: selectedGroupIds, + ids, }) } /> @@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = { missingExternalKey: number; }; +export type WorksmobileComparisonColumnKey = + | "status" + | "baronId" + | "baron" + | "baronOrg" + | "worksmobileId" + | "externalKey" + | "worksmobileDomain" + | "worksmobile" + | "worksmobileOrg" + | "manage"; + +export type WorksmobileComparisonColumnVisibility = Record< + WorksmobileComparisonColumnKey, + boolean +>; + +const worksmobileComparisonColumnOptions: Array<{ + key: WorksmobileComparisonColumnKey; + label: string; +}> = [ + { key: "status", label: "상태" }, + { key: "baronId", label: "Baron ID" }, + { key: "baron", label: "Baron" }, + { key: "baronOrg", label: "Baron 조직" }, + { key: "worksmobileId", label: "WORKS ID" }, + { key: "externalKey", label: "external_key" }, + { key: "worksmobileDomain", label: "WORKS 도메인" }, + { key: "worksmobile", label: "WORKS" }, + { key: "worksmobileOrg", label: "WORKS 조직" }, + { key: "manage", label: "관리" }, +]; + +export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility { + return { + status: true, + baronId: false, + baron: true, + baronOrg: true, + worksmobileId: false, + externalKey: false, + worksmobileDomain: true, + worksmobile: true, + worksmobileOrg: true, + manage: true, + }; +} + export function summarizeWorksmobileComparison( rows: WorksmobileComparisonItem[], ): WorksmobileComparisonSummary { @@ -480,6 +543,54 @@ export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) { return row.status === "missing_in_worksmobile" && Boolean(row.baronId); } +const immutableWorksmobileAccountEmails = new Set([ + "cyhan@samaneng.com", + "cyhan1@hanmaceng.co.kr", + "cyhan2@baroncs.co.kr", + "cyhan3@brsw.kr", + "su-@samaneng.com", +]); + +export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) { + return ( + row.resourceType === "USER" && + immutableWorksmobileAccountEmails.has( + row.worksmobileEmail?.trim().toLowerCase() ?? "", + ) + ); +} + +export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) { + if (row.baronId) { + return `${row.resourceType}:baron:${row.baronId}`; + } + if (row.worksmobileId) { + return `${row.resourceType}:works:${row.worksmobileId}`; + } + if (row.externalKey) { + return `${row.resourceType}:external:${row.externalKey}`; + } + return ""; +} + +export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) { + return ( + Boolean(getWorksmobileRowSelectionKey(row)) && + !isImmutableWorksmobileAccount(row) + ); +} + +export function getWorksmobileSelectedActionIds( + rows: WorksmobileComparisonItem[], + selectedKeys: string[], +) { + const selected = new Set(selectedKeys); + return rows + .filter((row) => selected.has(getWorksmobileRowSelectionKey(row))) + .map((row) => row.baronId) + .filter((id): id is string => Boolean(id)); +} + export function filterWorksmobileComparisonRows( rows: WorksmobileComparisonItem[], filters: WorksmobileComparisonFilter[], @@ -519,13 +630,56 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) { return details; } +export function buildWorksmobilePasswordManageUrl({ + tenantId, + domainId, + userIdNo, +}: { + tenantId?: string; + domainId?: number; + userIdNo?: string; +}) { + const normalizedTenantId = tenantId?.trim(); + const normalizedUserIdNo = userIdNo?.trim(); + if (!normalizedTenantId || !domainId || domainId <= 0 || !normalizedUserIdNo) { + return ""; + } + const url = new URL("https://auth.worksmobile.com/integrate/password/manage"); + url.searchParams.set("usage", "admin"); + url.searchParams.set("targetUserTenantId", normalizedTenantId); + url.searchParams.set("targetUserDomainId", String(domainId)); + url.searchParams.set("targetUserIdNo", normalizedUserIdNo); + url.searchParams.set( + "accessUrl", + "https://admin.worksmobile.com/assets/self-close.html", + ); + return url.toString(); +} + +export function canOpenWorksmobilePasswordManage( + row: WorksmobileComparisonItem, + tenantId?: string, +) { + return ( + row.resourceType === "USER" && + !isImmutableWorksmobileAccount(row) && + Boolean( + buildWorksmobilePasswordManageUrl({ + tenantId, + domainId: row.worksmobileDomainId, + userIdNo: row.worksmobileId, + }), + ) + ); +} + export const userFilterOptions: Array<{ value: WorksmobileComparisonFilter; label: string; }> = [ - { value: "baron_only", label: "Baron에만 있음" }, - { value: "works_only", label: "WORKS에만 있음" }, - { value: "matched", label: "양쪽에 다 있음" }, + { value: "baron_only", label: "바론에만 있음" }, + { value: "works_only", label: "웍스에만 있음" }, + { value: "matched", label: "양쪽 다 있음" }, ]; const worksmobileFilterStatuses: Record = @@ -605,8 +759,13 @@ function ComparisonTable({ title, rows, loading, - selectedIds, - onSelectedIdsChange, + selectedKeys, + onSelectedKeysChange, + filters, + onFiltersChange, + visibleColumns, + onVisibleColumnsChange, + passwordManageTenantId, actionLabel, actionDisabled, onCreateSelected, @@ -614,101 +773,238 @@ function ComparisonTable({ title: string; rows: WorksmobileComparisonItem[]; loading: boolean; - selectedIds: string[]; - onSelectedIdsChange: (ids: string[]) => void; + selectedKeys: string[]; + onSelectedKeysChange: (ids: string[]) => void; + filters?: WorksmobileComparisonFilter[]; + onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void; + visibleColumns: WorksmobileComparisonColumnVisibility; + onVisibleColumnsChange: React.Dispatch< + React.SetStateAction + >; + passwordManageTenantId?: string; actionLabel: string; actionDisabled: boolean; - onCreateSelected: () => void; + onCreateSelected: (ids: string[]) => void; }) { - const creatableIds = rows - .filter(canCreateWorksmobileRow) - .map((row) => row.baronId) - .filter((id): id is string => Boolean(id)); - const allCreatableSelected = - creatableIds.length > 0 && - creatableIds.every((id) => selectedIds.includes(id)); + const selectableKeys = rows + .filter(canSelectWorksmobileRow) + .map(getWorksmobileRowSelectionKey) + .filter(Boolean); + const selectedActionIds = getWorksmobileSelectedActionIds( + rows, + selectedKeys, + ); + const allSelectableSelected = + selectableKeys.length > 0 && + selectableKeys.every((key) => selectedKeys.includes(key)); + const visibleColumnCount = worksmobileComparisonColumnOptions.filter( + (column) => visibleColumns[column.key] !== false, + ).length; + const tableColSpan = visibleColumnCount + 1; const toggleAll = (checked: boolean | "indeterminate") => { - onSelectedIdsChange(checked === true ? creatableIds : []); + onSelectedKeysChange(checked === true ? selectableKeys : []); }; const toggleRow = ( - id: string | undefined, + row: WorksmobileComparisonItem, checked: boolean | "indeterminate", ) => { - if (!id) { + const key = getWorksmobileRowSelectionKey(row); + if (!key) { return; } if (checked === true) { - onSelectedIdsChange([...new Set([...selectedIds, id])]); + onSelectedKeysChange([...new Set([...selectedKeys, key])]); return; } - onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id)); + onSelectedKeysChange( + selectedKeys.filter((selectedKey) => selectedKey !== key), + ); + }; + + const openPasswordManage = (row: WorksmobileComparisonItem) => { + const url = buildWorksmobilePasswordManageUrl({ + tenantId: passwordManageTenantId, + domainId: row.worksmobileDomainId, + userIdNo: row.worksmobileId, + }); + if (!url) return; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + const toggleColumn = (key: WorksmobileComparisonColumnKey) => { + onVisibleColumnsChange((current) => ({ + ...current, + [key]: current[key] === false, + })); + }; + + const isColumnVisible = (key: WorksmobileComparisonColumnKey) => + visibleColumns[key] !== false; + + const toggleFilter = (filter: WorksmobileComparisonFilter) => { + if (!filters || !onFiltersChange) { + return; + } + onFiltersChange( + filters.includes(filter) + ? filters.filter((value) => value !== filter) + : [...filters, filter], + ); }; return ( -
-
-

{title}

- +
+
+
+

{title}

+ {filters && onFiltersChange && ( +
+ {userFilterOptions.map((option) => ( + + ))} +
+ )} +
+
+ + + + + + + {title} 컬럼 설정 + + 이 테이블에 표시할 비교 컬럼을 선택하세요. + + +
+ {worksmobileComparisonColumnOptions.map((column) => ( + + ))} +
+ + + + + +
+
+ +
-
-
+
+
- 상태 - - Baron ID - - - Baron - - - Baron 조직 - - - WORKS ID - - - external_key - - - WORKS 도메인 - - - WORKS - - - WORKS 조직 - + {isColumnVisible("status") && ( + 상태 + )} + {isColumnVisible("baronId") && ( + + Baron ID + + )} + {isColumnVisible("baron") && ( + + Baron + + )} + {isColumnVisible("baronOrg") && ( + + Baron 조직 + + )} + {isColumnVisible("worksmobileId") && ( + + WORKS ID + + )} + {isColumnVisible("externalKey") && ( + + external_key + + )} + {isColumnVisible("worksmobileDomain") && ( + + WORKS 도메인 + + )} + {isColumnVisible("worksmobile") && ( + + WORKS + + )} + {isColumnVisible("worksmobileOrg") && ( + + WORKS 조직 + + )} + {isColumnVisible("manage") && ( + 관리 + )} {loading && ( - + 불러오는 중... )} {!loading && rows.length === 0 && ( - + 표시할 차이가 없습니다. @@ -720,87 +1016,126 @@ function ComparisonTable({ - toggleRow(row.baronId, checked) - } + disabled={!canSelectWorksmobileRow(row)} + onCheckedChange={(checked) => toggleRow(row, checked)} /> - - - {getWorksmobileComparisonStatusLabel(row.status)} - - - - {row.baronId ?? "-"} - - -
-
{row.baronName ?? "-"}
-
- {row.baronEmail ?? ""} + {isColumnVisible("status") && ( + + + {getWorksmobileComparisonStatusLabel(row.status)} + + + )} + {isColumnVisible("baronId") && ( + + {row.baronId ?? "-"} + + )} + {isColumnVisible("baron") && ( + +
+
{row.baronName ?? "-"}
+
+ {row.baronEmail ?? ""} +
-
- - - - - - {row.worksmobileId ?? "-"} - - - {row.externalKey ?? "-"} - - - - - -
-
{formatWorksmobilePersonName(row) || "-"}
-
- {row.worksmobileEmail ?? ""} + + )} + {isColumnVisible("baronOrg") && ( + + + + )} + {isColumnVisible("worksmobileId") && ( + + {row.worksmobileId ?? "-"} + + )} + {isColumnVisible("externalKey") && ( + + {row.externalKey ?? "-"} + + )} + {isColumnVisible("worksmobileDomain") && ( + + + + )} + {isColumnVisible("worksmobile") && ( + +
+
{formatWorksmobilePersonName(row) || "-"}
+
+ {row.worksmobileEmail ?? ""} +
-
- - - - + + )} + {isColumnVisible("worksmobileOrg") && ( + + + + )} + {isColumnVisible("manage") && ( + + {row.resourceType === "USER" && ( + + )} + + )} ))} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 8f0ee7a1..d1e4b245 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -508,7 +508,9 @@ export type WorksmobileOverview = { tenant: TenantSummary; config: { enabled: boolean; + domainMappings?: Record; tokenConfigured: boolean; + adminTenantId?: string; }; recentJobs: WorksmobileOutboxItem[]; }; diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 1847ce68..0a150c9b 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -57,6 +57,7 @@ test.describe("Tenants Management", () => { }); test("should list tenants", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 700 }); const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea"; await page.route("**/api/v1/admin/tenants**", async (route) => { @@ -93,6 +94,15 @@ test.describe("Tenants Management", () => { timeout: 10000, }); await expect(page.locator("table")).toContainText(internalTenantId); + await expect(page.locator("table")).toContainText("COMPANY"); + await expect(page.locator("table")).not.toContainText("일반 기업"); + + const headerWhiteSpace = await page + .locator("table thead th") + .evaluateAll((headers) => + headers.map((header) => window.getComputedStyle(header).whiteSpace), + ); + expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true); }); test("should create a new tenant", async ({ page }) => { diff --git a/adminfront/tests/worksmobile.spec.ts b/adminfront/tests/worksmobile.spec.ts index 0068391a..dd36795e 100644 --- a/adminfront/tests/worksmobile.spec.ts +++ b/adminfront/tests/worksmobile.spec.ts @@ -197,7 +197,7 @@ test.describe("Worksmobile tenant management", () => { await expect(page.getByText("domainMappings")).not.toBeVisible(); await expect(page.getByText("SCIM token")).not.toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); - await expect(page.getByText("박웍스")).not.toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); await expect(page.getByText("WORKS 전용 조직")).toBeVisible(); await expect(page.getByText("기술본부", { exact: true })).toBeVisible(); await expect(page.getByText("parent-tech", { exact: true })).toBeVisible(); @@ -208,47 +208,47 @@ test.describe("Worksmobile tenant management", () => { const filterButtons = page .getByRole("button", { - name: /Baron에만 있음|WORKS에만 있음|양쪽에 다 있음/, + name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/, }) .allTextContents(); await expect.poll(() => filterButtons).toEqual([ - "Baron에만 있음", - "WORKS에만 있음", - "양쪽에 다 있음", + "바론에만 있음", + "웍스에만 있음", + "양쪽 다 있음", ]); - await page.getByRole("button", { name: "WORKS에만 있음" }).click(); - await expect(page.getByText("박웍스")).toBeVisible(); + await page.getByRole("button", { name: "웍스에만 있음" }).click(); + await expect(page.getByText("박웍스")).not.toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); - await page.getByRole("button", { name: "양쪽에 다 있음" }).click(); + await page.getByRole("button", { name: "양쪽 다 있음" }).click(); await expect(page.getByText("홍길동")).toHaveCount(2); await expect(page.getByText("기술기획", { exact: true })).toBeVisible(); await expect(page.getByText("team-tech", { exact: true })).toBeVisible(); await expect(page.getByText("WORKS 기술기획")).toBeVisible(); await expect(page.getByText("works-team-tech")).toBeVisible(); await expect(page.getByText("김누락")).toBeVisible(); - await expect(page.getByText("박웍스")).toBeVisible(); + await expect(page.getByText("박웍스")).not.toBeVisible(); - await page.getByRole("button", { name: "Baron에만 있음" }).click(); - await expect(page.getByText("홍길동")).toHaveCount(2); - await expect(page.getByText("김누락")).not.toBeVisible(); - await expect(page.getByText("박웍스")).toBeVisible(); - - await page.getByRole("button", { name: "WORKS에만 있음" }).click(); + await page.getByRole("button", { name: "바론에만 있음" }).click(); await expect(page.getByText("홍길동")).toHaveCount(2); await expect(page.getByText("김누락")).not.toBeVisible(); await expect(page.getByText("박웍스")).not.toBeVisible(); - await page.getByRole("button", { name: "양쪽에 다 있음" }).click(); + await page.getByRole("button", { name: "웍스에만 있음" }).click(); + await expect(page.getByText("홍길동")).toHaveCount(2); + await expect(page.getByText("김누락")).not.toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); + + await page.getByRole("button", { name: "양쪽 다 있음" }).click(); + await expect(page.getByText("김누락")).not.toBeVisible(); + await expect(page.getByText("박웍스")).toBeVisible(); + await expect(page.getByText("홍길동")).not.toBeVisible(); + + await page.getByRole("button", { name: "바론에만 있음" }).click(); await expect(page.getByText("김누락")).toBeVisible(); await expect(page.getByText("박웍스")).toBeVisible(); - await expect(page.getByText("홍길동")).toHaveCount(2); - - await page.getByRole("button", { name: "Baron에만 있음" }).click(); - await expect(page.getByText("김누락")).toBeVisible(); - await expect(page.getByText("박웍스")).not.toBeVisible(); await expect(page.getByText("홍길동")).not.toBeVisible(); await page @@ -360,4 +360,148 @@ test.describe("Worksmobile tenant management", () => { page.getByText(/WORKS API rejected user creation/), ).toBeVisible(); }); + + test("keeps wide comparison columns inside table scroll and blocks immutable WORKS accounts", async ({ + page, + }) => { + await page.setViewportSize({ width: 900, height: 700 }); + + await page.route("**/api/v1/**", async (route) => { + const url = new URL(route.request().url()); + const method = route.request().method(); + const headers = { "Access-Control-Allow-Origin": "*" }; + + if (url.pathname.endsWith("/user/me")) { + return route.fulfill({ + json: { id: "admin-user", name: "Admin", role: "super_admin" }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id") && + method === "GET" + ) { + return route.fulfill({ + json: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + }, + headers, + }); + } + + if ( + url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") && + method === "GET" + ) { + return route.fulfill({ + json: { + tenant: { + id: "hanmac-family-id", + name: "한맥 가족", + slug: "hanmac-family", + parentId: null, + }, + config: { + adminTenantId: "works-tenant-1", + }, + recentJobs: [], + }, + headers, + }); + } + + if ( + url.pathname.endsWith( + "/admin/tenants/hanmac-family-id/worksmobile/comparison", + ) && + method === "GET" + ) { + return route.fulfill({ + json: { + users: [ + { + resourceType: "USER", + worksmobileId: + "works-user-with-extra-long-identifier-for-scroll-check", + externalKey: "external-key-with-extra-long-identifier", + worksmobileName: "긴 WORKS 사용자", + worksmobileEmail: + "long-works-user-name-for-scroll@samaneng.com", + worksmobileDomainId: 300285955, + worksmobileDomainName: "samaneng.com", + worksmobilePrimaryOrgId: + "works-primary-org-with-extra-long-identifier", + worksmobilePrimaryOrgName: "긴 WORKS 조직", + status: "missing_in_baron", + }, + { + resourceType: "USER", + worksmobileId: "works-cyhan", + worksmobileName: "변경 불가 계정", + worksmobileEmail: "cyhan@samaneng.com", + worksmobileDomainId: 300285955, + worksmobileDomainName: "samaneng.com", + status: "missing_in_baron", + }, + ], + groups: [], + }, + headers, + }); + } + + return route.fulfill({ json: { items: [], total: 0 }, headers }); + }); + + await page.goto("/tenants/hanmac-family-id/worksmobile"); + await expect(page.getByText("긴 WORKS 사용자")).toBeVisible(); + + const userColumnButton = page + .getByRole("heading", { name: "구성원" }) + .locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]") + .getByRole("button", { name: "컬럼 설정" }); + await userColumnButton.click(); + + const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" }); + await dialog.getByLabel("Baron ID").check(); + await dialog.getByLabel("WORKS ID").check(); + await dialog.getByLabel("external_key").check(); + await dialog.getByRole("button", { name: "닫기" }).click(); + + const pageOverflow = await page.evaluate(() => ({ + documentScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + })); + expect( + Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth), + ).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1); + + const userTableScroll = await page.locator("table").first().evaluate( + (table) => { + const container = table.parentElement?.parentElement as HTMLElement; + return { + clientWidth: container.clientWidth, + overflowX: window.getComputedStyle(container).overflowX, + scrollWidth: container.scrollWidth, + }; + }, + ); + expect(userTableScroll.overflowX).toBe("auto"); + expect(userTableScroll.scrollWidth).toBeGreaterThan( + userTableScroll.clientWidth, + ); + + const immutableRow = page.getByRole("row", { + name: /cyhan@samaneng\.com/, + }); + await expect(immutableRow.getByRole("checkbox")).toBeDisabled(); + await expect( + immutableRow.getByRole("button", { name: /비밀번호 관리/ }), + ).toBeDisabled(); + }); }); diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 6b77ec12..a2f495da 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -33,6 +33,7 @@ type WorksmobileConfigSummary struct { Enabled bool `json:"enabled"` DomainMappings map[string]int64 `json:"domainMappings"` TokenConfigured bool `json:"tokenConfigured"` + AdminTenantID string `json:"adminTenantId,omitempty"` } type WorksmobileTenantOverview struct { @@ -115,6 +116,7 @@ func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID Enabled: WorksmobileEnabled(tenant.Config), DomainMappings: WorksmobileDomainMappings(tenant.Config), TokenConfigured: worksmobileDirectoryAuthConfigured(), + AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")), }, RecentJobs: jobs, }, nil diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index d2c9e1b5..4f3fe8e6 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -56,6 +56,26 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te require.Empty(t, outboxRepo.created) } +func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) { + t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1") + root := domain.Tenant{ + ID: "root-tenant", + Slug: HanmacFamilyTenantSlug, + Name: "한맥가족", + } + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}}, + &fakeWorksmobileUserRepo{}, + &fakeWorksmobileOutboxRepo{}, + nil, + ) + + overview, err := service.GetTenantOverview(context.Background(), root.ID) + + require.NoError(t, err) + require.Equal(t, "works-tenant-1", overview.Config.AdminTenantID) +} + func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) { parentID := "root-tenant" root := domain.Tenant{ diff --git a/docs/worksmobile-directory-sync-technical-review.md b/docs/worksmobile-directory-sync-technical-review.md index 07679c5a..76849f3d 100644 --- a/docs/worksmobile-directory-sync-technical-review.md +++ b/docs/worksmobile-directory-sync-technical-review.md @@ -192,6 +192,25 @@ Worksmobile 구성원 수정 API에는 PUT(`user-update-put`)과 PATCH(`user-upd - 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다. - PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다. +### 구성원 비밀번호 관리 링크 + +Baron SSO는 생성 이후 WORKS Mobile 비밀번호 값을 직접 수정하지 않습니다. 운영자가 비밀번호 수정을 요청할 때는 해당 WORKS 계정의 식별자를 이용해 WORKS Mobile 관리자 비밀번호 관리 화면을 새 창으로 엽니다. + +사용 URL: + +```text +https://auth.worksmobile.com/integrate/password/manage?usage=admin&targetUserTenantId={회사테넌트}&targetUserDomainId={회사도메인}&targetUserIdNo={변경대상works_USER_ID}&accessUrl=https://admin.worksmobile.com/assets/self-close.html +``` + +전제와 기준: + +- 브라우저 사용자는 `auth.worksmobile.com`에 관리자 권한으로 로그인되어 있어야 합니다. +- `targetUserTenantId`는 Baron tenant UUID가 아니라 WORKS Mobile 회사 tenant 식별자입니다. Baron SSO backend는 `WORKS_ADMIN_TENANT_ID` 환경 변수로 이 값을 adminfront overview에 노출합니다. +- `targetUserDomainId`는 WORKS Mobile 비교 결과의 `worksmobileDomainId`를 사용합니다. +- `targetUserIdNo`는 WORKS Mobile 비교 결과의 `worksmobileId`를 사용합니다. +- adminfront는 세 값이 모두 있을 때만 비밀번호 관리 버튼을 활성화합니다. +- 이 링크는 WORKS Mobile 관리자 화면을 여는 기능이며, Baron SSO backend에서 password 또는 `passwordConfig` 변경 API를 호출하지 않습니다. + ## 비동기 아키텍처 권장안 Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다. From 13dee9ae9b4585403c0e9691010852486210462b Mon Sep 17 00:00:00 2001 From: Lectom Date: Wed, 6 May 2026 16:14:52 +0900 Subject: [PATCH 02/15] =?UTF-8?q?adminfront=20=EA=B0=9C=EC=9A=94=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/features/auth/AuthPage.tsx | 118 +--- .../components/PermissionChecker.tsx | 14 +- .../overview/GlobalOverviewPage.test.tsx | 186 +++++ .../features/overview/GlobalOverviewPage.tsx | 635 +++++++++++++----- adminfront/src/lib/adminApi.ts | 50 ++ backend/cmd/server/main.go | 19 + backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/domain/models.go | 1 + backend/internal/domain/rp_usage_event.go | 101 +++ backend/internal/handler/admin_handler.go | 150 ++++- .../internal/handler/admin_handler_test.go | 156 +++++ backend/internal/handler/auth_handler.go | 113 ++++ .../handler/auth_handler_client_test.go | 15 +- .../handler/auth_handler_consent_test.go | 25 + backend/internal/handler/common_test.go | 17 + .../middleware/audit_middleware_test.go | 8 + .../internal/repository/clickhouse_repo.go | 199 ++++++ backend/internal/repository/main_test.go | 2 +- .../repository/rp_usage_outbox_repository.go | 91 +++ .../service/rp_usage_event_emitter.go | 67 ++ .../service/rp_usage_event_emitter_test.go | 132 ++++ .../service/rp_usage_projector_worker.go | 82 +++ docker/ory/vector/vector.toml | 134 +++- test/oathkeeper_access_log_e2e_test.sh | 63 ++ 24 files changed, 2082 insertions(+), 297 deletions(-) rename adminfront/src/features/{overview => auth}/components/PermissionChecker.tsx (90%) create mode 100644 adminfront/src/features/overview/GlobalOverviewPage.test.tsx create mode 100644 backend/internal/domain/rp_usage_event.go create mode 100644 backend/internal/handler/admin_handler_test.go create mode 100644 backend/internal/repository/rp_usage_outbox_repository.go create mode 100644 backend/internal/service/rp_usage_event_emitter.go create mode 100644 backend/internal/service/rp_usage_event_emitter_test.go create mode 100644 backend/internal/service/rp_usage_projector_worker.go diff --git a/adminfront/src/features/auth/AuthPage.tsx b/adminfront/src/features/auth/AuthPage.tsx index 015bbb67..002c69e6 100644 --- a/adminfront/src/features/auth/AuthPage.tsx +++ b/adminfront/src/features/auth/AuthPage.tsx @@ -1,109 +1,25 @@ -import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react"; - -const flows = [ - { - title: "Admin login", - description: - "Enforce short TTL and step-up MFA. Keep admin session separate from app session.", - pill: "15m TTL", - }, - { - title: "Tenant pick", - description: - "Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.", - pill: "Header-ready", - }, - { - title: "Device approval", - description: - "If app session exists and user opts in, use push/deeplink approval as MFA replacement.", - pill: "App session", - }, -]; +import { KeyRound } from "lucide-react"; +import PermissionChecker from "./components/PermissionChecker"; function AuthPage() { return ( -
-
-
-
-

- Admin auth -

-

Admin auth guardrails

-

- Build the admin-only login flow first, keeping app login separate. - Respect the “fallback only when user chooses” rule for SMS/email - vs app approval. -

-
-
- - IDP session placeholder - - -
-
-
- -
- {flows.map((flow) => ( -
-
- {flow.pill} - -
-

{flow.title}

-

- {flow.description} -

-
- ))} -
- -
-
-
- - - App-based approvals - -
-

- App session as MFA replacement -

-

- If the admin keeps the mobile app signed in and opts in, use - push/deeplink approval instead of OTP. Otherwise fall back to - SMS/email based on user choice. +

+
+
+

+ Admin auth +

+

+ + 인증가드 +

+

+ 관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.

-
-
- - - TTL discipline - -
-

- Keep admin sessions short -

-

- Default admin TTL is 15 minutes. Show countdown and nudge re-auth - with step-up MFA when critical actions (rotate secret, export logs) - happen. -

-
-
+
+ +
); } diff --git a/adminfront/src/features/overview/components/PermissionChecker.tsx b/adminfront/src/features/auth/components/PermissionChecker.tsx similarity index 90% rename from adminfront/src/features/overview/components/PermissionChecker.tsx rename to adminfront/src/features/auth/components/PermissionChecker.tsx index 8b2e090b..d2965ffc 100644 --- a/adminfront/src/features/overview/components/PermissionChecker.tsx +++ b/adminfront/src/features/auth/components/PermissionChecker.tsx @@ -44,7 +44,7 @@ function PermissionChecker() { const result = checkMutation.data; return ( - + @@ -100,7 +100,7 @@ function PermissionChecker() { @@ -108,17 +108,17 @@ function PermissionChecker() { {checkMutation.isSuccess && result && (
{result.allowed ? ( <>
Access ALLOWED
-

+

해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)

@@ -127,7 +127,7 @@ function PermissionChecker() { <>
Access DENIED
-

+

해당 사용자는 요청한 리소스에 대해 권한이 없습니다.

diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx new file mode 100644 index 00000000..fbf9f83a --- /dev/null +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -0,0 +1,186 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type React from "react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchAdminRPUsageDaily } from "../../lib/adminApi"; +import AuthPage from "../auth/AuthPage"; +import GlobalOverviewPage from "./GlobalOverviewPage"; + +vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ role: "super_admin" })), + fetchAdminOverviewStats: vi.fn(async () => ({ + totalTenants: 10, + oidcClients: 3, + auditEvents24h: 18, + })), + fetchTenants: vi.fn(async () => ({ + items: [ + { + id: "company-1", + type: "COMPANY", + name: "한맥", + slug: "hanmac", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-05-06T00:00:00Z", + updatedAt: "2026-05-06T00:00:00Z", + }, + { + id: "org-1", + type: "ORGANIZATION", + name: "개발팀", + slug: "dev-team", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-05-06T00:00:00Z", + updatedAt: "2026-05-06T00:00:00Z", + }, + { + id: "personal-1", + type: "PERSONAL", + name: "개인", + slug: "personal", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-05-06T00:00:00Z", + updatedAt: "2026-05-06T00:00:00Z", + }, + ], + limit: 1000, + offset: 0, + total: 3, + })), + fetchAdminRPUsageDaily: vi.fn(async () => ({ + days: 14, + period: "day", + items: [ + { + date: "2026-05-05", + tenantId: "company-1", + tenantType: "COMPANY", + tenantName: "한맥", + clientId: "orgfront", + clientName: "OrgFront", + loginRequests: 12, + otherRequests: 4, + uniqueSubjects: 8, + }, + { + date: "2026-05-06", + tenantId: "company-1", + tenantType: "COMPANY", + tenantName: "한맥", + clientId: "adminfront", + clientName: "AdminFront", + loginRequests: 7, + otherRequests: 3, + uniqueSubjects: 5, + }, + { + date: "2026-09-28", + tenantId: "company-1", + tenantType: "COMPANY", + tenantName: "한맥", + clientId: "devfront", + clientName: "DevFront", + loginRequests: 2, + otherRequests: 1, + uniqueSubjects: 2, + }, + ], + })), +})); + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + {ui} + , + ); +} + +describe("admin overview and auth guard pages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders usage trend chart without quick navigation or permission checker", async () => { + renderWithProviders(); + + expect( + await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"), + ).toBeInTheDocument(); + expect( + await screen.findByLabelText("일 단위 RP 요청 현황"), + ).toBeInTheDocument(); + expect(await screen.findByText("05.05")).toBeInTheDocument(); + expect(await screen.findByText("05.06")).toBeInTheDocument(); + expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument(); + expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument(); + expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument(); + expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument(); + }); + + it("renders overview summary metrics from the admin stats API", async () => { + renderWithProviders(); + + expect( + (await screen.findByText("전체 테넌트 수")).parentElement, + ).toHaveTextContent("10"); + expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent( + "3", + ); + expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent( + "18", + ); + }); + + it("changes the RP usage perspective and targets a permitted organization", async () => { + renderWithProviders(); + + await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"); + fireEvent.click(screen.getByRole("button", { name: "주" })); + expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0); + expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0); + fireEvent.click(screen.getByRole("button", { name: "월" })); + fireEvent.change(screen.getByLabelText("조직 검색"), { + target: { value: "개발" }, + }); + fireEvent.change(screen.getByLabelText("대상 조직"), { + target: { value: "org-1" }, + }); + + await waitFor(() => { + expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({ + days: 90, + period: "month", + tenantId: "org-1", + }); + }); + expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument(); + expect(await screen.findAllByText("05월")).not.toHaveLength(0); + }); + + it("moves the permission checker to the auth guard page and removes mock guardrails", () => { + renderWithProviders(); + + expect(screen.getByText("인증가드")).toBeInTheDocument(); + expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument(); + expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument(); + expect( + screen.queryByText("IDP session placeholder"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Admin login")).not.toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index e3d3a14e..6f1be04f 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -1,33 +1,433 @@ +import { useQuery } from "@tanstack/react-query"; import { Activity, - ArrowUpRight, + BarChart3, Database, - Key, - PlusCircle, ShieldCheck, Users, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { type ReactNode, useMemo, useState } from "react"; import { RoleGuard } from "../../components/auth/RoleGuard"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../../components/ui/card"; + type RPUsageDailyMetric, + type RPUsagePeriod, + type TenantSummary, + fetchAdminOverviewStats, + fetchAdminRPUsageDaily, + fetchTenants, +} from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import PermissionChecker from "./components/PermissionChecker"; + +type DailyPoint = { + date: string; + loginRequests: number; + otherRequests: number; +}; + +type SeriesSummary = { + key: string; + tenantLabel: string; + clientLabel: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] { + const byDate = new Map(); + for (const row of rows) { + const current = + byDate.get(row.date) ?? + ({ + date: row.date, + loginRequests: 0, + otherRequests: 0, + } satisfies DailyPoint); + current.loginRequests += row.loginRequests; + current.otherRequests += row.otherRequests; + byDate.set(row.date, current); + } + return Array.from(byDate.values()).sort((a, b) => + a.date.localeCompare(b.date), + ); +} + +function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { + const bySeries = new Map(); + for (const row of rows) { + const key = `${row.tenantId}:${row.clientId}`; + const current = + bySeries.get(key) ?? + ({ + key, + tenantLabel: row.tenantName || row.tenantId || "-", + clientLabel: row.clientName || row.clientId, + loginRequests: 0, + otherRequests: 0, + uniqueSubjects: 0, + } satisfies SeriesSummary); + current.loginRequests += row.loginRequests; + current.otherRequests += row.otherRequests; + current.uniqueSubjects = Math.max( + current.uniqueSubjects, + row.uniqueSubjects, + ); + bySeries.set(key, current); + } + return Array.from(bySeries.values()) + .sort( + (a, b) => + b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests), + ) + .slice(0, 5); +} + +function parseDateParts(date: string) { + const parts = date.split("-"); + if (parts.length === 3) { + return { + year: Number(parts[0]), + month: Number(parts[1]), + day: Number(parts[2]), + monthText: parts[1], + dayText: parts[2], + }; + } + return null; +} + +function getISOWeekNumber(year: number, month: number, day: number) { + const date = new Date(Date.UTC(year, month - 1, day)); + const dayOfWeek = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); +} + +function getISOWeekThursday(year: number, month: number, day: number) { + const date = new Date(Date.UTC(year, month - 1, day)); + const dayOfWeek = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek); + return date; +} + +function formatPeriodLabel(date: string, period: RPUsagePeriod) { + const parts = parseDateParts(date); + if (!parts) { + return date; + } + if (period === "month") { + return `${parts.monthText}월`; + } + if (period === "week") { + const weekNumber = String( + getISOWeekNumber(parts.year, parts.month, parts.day), + ).padStart(2, "0"); + const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day); + const weekMonth = weekThursday.getUTCMonth() + 1; + const weekDay = weekThursday.getUTCDate(); + const weekMonthText = String(weekMonth).padStart(2, "0"); + const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7))); + return `${weekNumber}(${weekMonthText}월${weekOfMonth}주)`; + } + return `${parts.monthText}.${parts.dayText}`; +} + +function OverviewMetric({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( + + {icon} + {label} + {value} + + ); +} + +function RPUsageMixedChart({ + rows, + filters, + period, +}: { + rows: RPUsageDailyMetric[]; + filters: ReactNode; + period: RPUsagePeriod; +}) { + const daily = summarizeDaily(rows); + const series = summarizeSeries(rows); + const chartWidth = 720; + const chartHeight = 230; + const padX = 48; + const padTop = 32; + const padBottom = 34; + const innerWidth = chartWidth - padX * 2; + const innerHeight = chartHeight - padTop - padBottom; + const maxValue = Math.max( + 1, + ...daily.map((point) => point.loginRequests + point.otherRequests), + ...daily.map((point) => point.loginRequests), + ); + const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth; + const barWidth = Math.min(28, Math.max(10, slot * 0.42)); + const y = (value: number) => + padTop + innerHeight - (value / maxValue) * innerHeight; + const x = (index: number) => padX + slot * index + slot / 2; + const linePoints = daily + .map((point, index) => `${x(index)},${y(point.loginRequests)}`) + .join(" "); + + return ( +
+
+
+ +

+ 회사별 앱별 로그인요청/기타 요청 현황 +

+
+ {filters} +
+ + {daily.length === 0 ? ( +
+ 표시할 RP 이용 집계가 없습니다. +
+ ) : ( +
+ + 일 단위 RP 요청 현황 + + + + 기타 요청 + + + + 로그인 요청 + + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const gridY = padTop + innerHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => { + const center = x(index); + const otherHeight = + (point.otherRequests / maxValue) * innerHeight; + return ( + + + + {formatPeriodLabel(point.date, period)} + + + ); + })} + + {daily.map((point, index) => ( + + ))} + +
+ )} + + {series.length > 0 && ( +
+ {series.map((item) => ( +
+ {item.clientLabel} + + {item.tenantLabel} + + + 로그인 {item.loginRequests.toLocaleString()} / 기타{" "} + {item.otherRequests.toLocaleString()} / 사용자{" "} + {item.uniqueSubjects.toLocaleString()} + +
+ ))} +
+ )} +
+ ); +} function GlobalOverviewPage() { + const [period, setPeriod] = useState("day"); + const [tenantSearch, setTenantSearch] = useState(""); + const [selectedTenantId, setSelectedTenantId] = useState(""); + const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; + const statsQuery = useQuery({ + queryKey: ["admin-overview-stats"], + queryFn: fetchAdminOverviewStats, + retry: false, + }); + const tenantsQuery = useQuery({ + queryKey: ["admin-overview-tenant-options"], + queryFn: () => fetchTenants(1000, 0), + retry: false, + }); + const tenantOptions = useMemo(() => { + const term = tenantSearch.trim().toLowerCase(); + return (tenantsQuery.data?.items ?? []) + .filter( + (tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION", + ) + .filter((tenant) => { + if (!term) return true; + return ( + tenant.name.toLowerCase().includes(term) || + tenant.slug.toLowerCase().includes(term) || + tenant.id.toLowerCase().includes(term) + ); + }); + }, [tenantSearch, tenantsQuery.data?.items]); + const usageQuery = useQuery({ + queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId], + queryFn: () => + fetchAdminRPUsageDaily({ + days: usageDays, + period, + tenantId: selectedTenantId || undefined, + }), + retry: false, + }); + const stats = statsQuery.data; + const usageRows = usageQuery.data?.items ?? []; + const metric = (value: number | undefined) => + value === undefined ? "-" : value.toLocaleString(); + const chartFilters = ( +
+
+ {[ + ["day", "일"], + ["week", "주"], + ["month", "월"], + ].map(([value, label]) => ( + + ))} +
+ setTenantSearch(event.target.value)} + placeholder="조직 검색" + className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44" + /> + +
+ ); + return ( -
+
-

+

{t("ui.admin.overview.title", "Dashboard")}

-

+

{t( "msg.admin.overview.description", "시스템 전반의 주요 현황을 확인하고 관리합니다.", @@ -36,166 +436,61 @@ function GlobalOverviewPage() {

-
+
- - - - {t("ui.admin.overview.summary.total_tenants", "총 테넌트")} - -
- -
-
- -
-
-

- 활성화된 테넌트 수 -

-
-
- - - - {t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")} - -
- -
-
- -
-
-

- 등록된 OIDC 앱 -

-
-
+ } + label={t( + "ui.admin.overview.summary.total_tenants", + "전체 테넌트 수", + )} + value={metric(stats?.totalTenants)} + /> + } + label={t( + "ui.admin.overview.summary.oidc_clients", + "OIDC 클라이언트", + )} + value={metric(stats?.oidcClients)} + />
- - - - - {t( - "ui.admin.overview.summary.audit_events_24h", - "최근 감사 로그 (24h)", - )} - -
- -
-
- -
-
-

- 발생한 이벤트 수 -

-
-
- - - - - {t("ui.admin.overview.summary.policy_gate", "정책 상태")} - -
- -
-
- -
- Active -
-

- 접근 제어 정상 동작 -

-
-
+ } + label={t( + "ui.admin.overview.summary.audit_events_24h", + "24시간 이벤트", + )} + value={metric(stats?.auditEvents24h)} + /> + } + label={t("ui.admin.overview.summary.policy_gate", "정책 상태")} + value="Active" + />
-
-

- {t("ui.admin.overview.quick_links.title", "빠른 작업")} -

-
- - -
- -
-
-

- 테넌트 추가 -

-

- 새로운 조직이나 그룹을 생성합니다. -

-
- -
- - -
- -
-
-

- 사용자 관리 -

-

- 전체 사용자를 조회하고 관리합니다. -

-
- - - - -
- -
-
-

- API 키 관리 -

-

- 시스템 연동을 위한 키를 발급합니다. -

-
- -
- - -
- -
-
-

- 감사 로그 -

-

- 보안 이벤트를 모니터링합니다. -

-
- -
-
- - -
- -
-
+ {usageQuery.isError ? ( +
+
+

+ 회사별 앱별 로그인요청/기타 요청 현황 +

+ {chartFilters} +
+
+ RP 이용 통계 Query API 응답을 확인할 수 없습니다. backend 재시작 + 이후 `rp_usage_daily_aggregate`가 준비되면 이 영역에 일 단위 + 그래프가 표시됩니다. +
+
+ ) : ( + + )}
); } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index d1e4b245..d7a8c274 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -101,6 +101,33 @@ export type RoleListResponse = { total: number; }; +export type RPUsageDailyMetric = { + date: string; + tenantId: string; + tenantType: string; + tenantName?: string; + clientId: string; + clientName: string; + loginRequests: number; + otherRequests: number; + uniqueSubjects: number; +}; + +export type RPUsagePeriod = "day" | "week" | "month"; + +export type RPUsageDailyResponse = { + items: RPUsageDailyMetric[]; + days: number; + period: RPUsagePeriod; + tenantId?: string; +}; + +export type AdminOverviewStats = { + totalTenants: number; + oidcClients: number; + auditEvents24h: number; +}; + export async function fetchAuditLogs(limit = 50, cursor?: string) { const { data } = await apiClient.get("/v1/audit", { params: { limit, cursor }, @@ -108,6 +135,29 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) { return data; } +export async function fetchAdminOverviewStats() { + const { data } = await apiClient.get("/v1/admin/stats"); + return data; +} + +export async function fetchAdminRPUsageDaily({ + days = 14, + period = "day", + tenantId, +}: { + days?: number; + period?: RPUsagePeriod; + tenantId?: string; +} = {}) { + const { data } = await apiClient.get( + "/v1/admin/rp-usage/daily", + { + params: { days, period, tenantId: tenantId || undefined }, + }, + ); + return data; +} + export async function fetchTenants(limit = 50, offset = 0, parentId?: string) { const { data } = await apiClient.get( "/v1/admin/tenants", diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 39a99db8..3fb5f052 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -183,11 +183,15 @@ func main() { chDB := getEnv("CLICKHOUSE_DB", "baron_sso") var auditRepo domain.AuditRepository + var rpUsageProjectionRepo domain.RPUsageProjectionRepository + var rpUsageQueryRepo domain.RPUsageQueryRepository if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) auditRepo = nil // Explicitly set to nil interface } else { auditRepo = repo + rpUsageProjectionRepo = repo + rpUsageQueryRepo = repo slog.Info("✅ Connected to ClickHouse") } @@ -297,6 +301,7 @@ func main() { userGroupRepo := repository.NewUserGroupRepository(db) userRepo := repository.NewUserRepository(db) ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init + rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db) worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) sharedLinkRepo := repository.NewSharedLinkRepository(db) kratosAdminService := service.NewKratosAdminService() @@ -323,6 +328,14 @@ func main() { worksmobileRelayWorker := service.NewWorksmobileRelayWorker(worksmobileOutboxRepo, worksmobileClient) go worksmobileRelayWorker.Start(context.Background()) slog.Info("✅ Worksmobile Relay Worker started") + rpUsageEmitter := service.NewRPUsageEventEmitter(rpUsageOutboxRepo) + if rpUsageProjectionRepo != nil { + rpUsageProjectorWorker := service.NewRPUsageProjectorWorker(rpUsageOutboxRepo, rpUsageProjectionRepo) + go rpUsageProjectorWorker.Start(context.Background()) + slog.Info("✅ RP Usage Projector Worker started") + } else { + slog.Warn("RP Usage Projector Worker skipped because ClickHouse is unavailable") + } sharedLinkService := service.NewSharedLinkService(sharedLinkRepo) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService) tenantService.SetKetoService(ketoService) // Keto 주입 @@ -342,7 +355,12 @@ func main() { authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler.HeadlessJWKS = headlessJWKSCache authHandler.RPUserMetadataRepo = rpUserMetadataRepo + authHandler.RPUsageSink = rpUsageEmitter adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) + adminHandler.RPUsageQueries = rpUsageQueryRepo + adminHandler.TenantRepo = tenantRepo + adminHandler.Hydra = hydraService + adminHandler.AuditRepo = auditRepo devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo @@ -674,6 +692,7 @@ func main() { admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) + admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily) // Tenant Management (Mixed roles, handler filters results) admin.Get("/tenants", requireAnyUser, tenantHandler.ListTenants) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index ddb4bfe5..2a38fb4c 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -45,6 +45,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientSecret{}, &domain.ClientConsent{}, &domain.KetoOutbox{}, + &domain.RPUsageEvent{}, &domain.WorksmobileOutbox{}, &domain.WorksmobileResourceMapping{}, &domain.SharedLink{}, diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 248618ea..1bacb35d 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -26,6 +26,7 @@ type AuditRepository interface { Create(log *AuditLog) error FindPage(ctx context.Context, limit int, cursor *AuditCursor, tenantID string) ([]AuditLog, error) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error) + CountEventsSince(ctx context.Context, since time.Time) (int64, error) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) Ping(ctx context.Context) error diff --git a/backend/internal/domain/rp_usage_event.go b/backend/internal/domain/rp_usage_event.go new file mode 100644 index 00000000..d551bf45 --- /dev/null +++ b/backend/internal/domain/rp_usage_event.go @@ -0,0 +1,101 @@ +package domain + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "gorm.io/gorm" +) + +const ( + RPUsageOutboxStatusPending = "pending" + RPUsageOutboxStatusProcessing = "processing" + RPUsageOutboxStatusProcessed = "processed" + RPUsageOutboxStatusFailed = "failed" +) + +const ( + RPUsageEventTypeAuthorizationGranted = "rp_usage.authorization_granted" + RPUsageEventTypeAuthorizationRevoked = "rp_usage.authorization_revoked" +) + +const ( + RPUsageTenantTypeCompany = TenantTypeCompany + RPUsageTenantTypeOrganization = TenantTypeOrganization +) + +type RPUsageEvent struct { + ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` + EventType string `gorm:"not null;index:idx_rp_usage_outbox_event" json:"eventType"` + Subject string `gorm:"not null;index:idx_rp_usage_outbox_subject" json:"subject"` + TenantID string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantId,omitempty"` + TenantType string `gorm:"index:idx_rp_usage_outbox_tenant" json:"tenantType,omitempty"` + ClientID string `gorm:"not null;index:idx_rp_usage_outbox_client" json:"clientId"` + ClientName string `json:"clientName,omitempty"` + SessionID string `gorm:"index" json:"sessionId,omitempty"` + Scopes pq.StringArray `gorm:"type:text[]" json:"scopes,omitempty"` + Source string `gorm:"not null;index" json:"source"` + CorrelationID string `gorm:"index" json:"correlationId,omitempty"` + Payload JSONMap `gorm:"type:jsonb" json:"payload,omitempty"` + DedupeKey string `gorm:"uniqueIndex" json:"dedupeKey"` + Status string `gorm:"default:'pending';index" json:"status"` + RetryCount int `gorm:"default:0" json:"retryCount"` + LastError string `json:"lastError,omitempty"` + NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"` + OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"` + ProcessedAt *time.Time `json:"processedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (e *RPUsageEvent) TableName() string { + return "rp_usage_outbox" +} + +func (e *RPUsageEvent) BeforeCreate(tx *gorm.DB) error { + if e.ID == "" { + e.ID = uuid.NewString() + } + if e.Status == "" { + e.Status = RPUsageOutboxStatusPending + } + if e.OccurredAt.IsZero() { + e.OccurredAt = time.Now() + } + if e.Payload == nil { + e.Payload = JSONMap{} + } + return nil +} + +type RPUsageEventSink interface { + EmitRPUsageEvent(ctx context.Context, event RPUsageEvent) error +} + +type RPUsageProjectionRepository interface { + CreateRPUsageEvent(ctx context.Context, event RPUsageEvent) error +} + +type RPUsageDailyMetric struct { + Date string `json:"date"` + TenantID string `json:"tenantId"` + TenantType string `json:"tenantType"` + TenantName string `json:"tenantName,omitempty"` + ClientID string `json:"clientId"` + ClientName string `json:"clientName"` + LoginRequests uint64 `json:"loginRequests"` + OtherRequests uint64 `json:"otherRequests"` + UniqueSubjects uint64 `json:"uniqueSubjects"` +} + +type RPUsageQuery struct { + Days int + Period string + TenantID string +} + +type RPUsageQueryRepository interface { + FindRPUsage(ctx context.Context, query RPUsageQuery) ([]RPUsageDailyMetric, error) +} diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 42ee1815..84d1ce0b 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,17 +1,29 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" + "context" "runtime" + "strconv" + "strings" "time" "github.com/gofiber/fiber/v2" ) +type adminHydraClientLister interface { + ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error) +} + type AdminHandler struct { - Keto service.KetoService - KetoOutbox repository.KetoOutboxRepository + Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository + RPUsageQueries domain.RPUsageQueryRepository + TenantRepo repository.TenantRepository + Hydra adminHydraClientLister + AuditRepo domain.AuditRepository } func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { @@ -21,6 +33,76 @@ func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxR } } +func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error { + if h == nil || h.RPUsageQueries == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "error": "rp usage query service unavailable", + }) + } + days := 14 + if raw := c.Query("days"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + days = parsed + } + } + period := normalizeRPUsagePeriod(c.Query("period")) + tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId"))) + if !allowed { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "forbidden: tenant rp usage stats permission denied", + }) + } + items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{ + Days: days, + Period: period, + TenantID: tenantID, + }) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": err.Error(), + }) + } + return c.JSON(fiber.Map{ + "items": items, + "days": days, + "period": period, + "tenantId": tenantID, + }) +} + +func normalizeRPUsagePeriod(period string) string { + switch strings.ToLower(strings.TrimSpace(period)) { + case "week": + return "week" + case "month": + return "month" + default: + return "day" + } +} + +func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) { + profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin { + return requestedTenantID, true + } + tenantID := requestedTenantID + if tenantID == "" && profile != nil && profile.TenantID != nil { + tenantID = strings.TrimSpace(*profile.TenantID) + } + if tenantID == "" { + return "", false + } + if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" { + return "", false + } + allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats") + if err != nil || !allowed { + return "", false + } + return tenantID, true +} + func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } @@ -29,10 +111,14 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { var m runtime.MemStats runtime.ReadMemStats(&m) + ctx := c.Context() stats := fiber.Map{ - "goroutines": runtime.NumGoroutine(), - "cpus": runtime.NumCPU(), + "totalTenants": h.countTenants(ctx), + "oidcClients": h.countOIDCClients(ctx), + "auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)), + "goroutines": runtime.NumGoroutine(), + "cpus": runtime.NumCPU(), "memory": fiber.Map{ "alloc": m.Alloc, "totalAlign": m.TotalAlloc, @@ -44,3 +130,59 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(stats) } + +func (h *AdminHandler) countTenants(ctx context.Context) int64 { + if h == nil || h.TenantRepo == nil { + return 0 + } + _, total, err := h.TenantRepo.List(ctx, 1, 0, "") + if err != nil { + return 0 + } + return total +} + +func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 { + if h == nil || h.Hydra == nil { + return 0 + } + const pageSize = 500 + var total int64 + for offset := 0; ; offset += pageSize { + clients, err := h.Hydra.ListClients(ctx, pageSize, offset) + if err != nil { + return total + } + for _, client := range clients { + if isHiddenSystemClient(client) { + continue + } + total++ + } + if len(clients) < pageSize { + break + } + } + return total +} + +func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 { + if h == nil || h.AuditRepo == nil { + return 0 + } + count, err := h.AuditRepo.CountEventsSince(ctx, since) + if err == nil && count > 0 { + return count + } + logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "") + if pageErr != nil { + return count + } + var fallbackCount int64 + for _, log := range logs { + if !log.Timestamp.Before(since) { + fallbackCount++ + } + } + return fallbackCount +} diff --git a/backend/internal/handler/admin_handler_test.go b/backend/internal/handler/admin_handler_test.go new file mode 100644 index 00000000..b392787e --- /dev/null +++ b/backend/internal/handler/admin_handler_test.go @@ -0,0 +1,156 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" +) + +type fakeRPUsageQueryRepo struct { + query domain.RPUsageQuery + items []domain.RPUsageDailyMetric +} + +func (f *fakeRPUsageQueryRepo) FindRPUsage(ctx context.Context, query domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) { + f.query = query + return f.items, nil +} + +type fakeAdminKeto struct { + allowed bool + subject string + object string + relation string +} + +func (f *fakeAdminKeto) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + f.subject = subject + f.object = object + f.relation = relation + return f.allowed, nil +} + +func (f *fakeAdminKeto) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + return nil +} + +func (f *fakeAdminKeto) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return nil +} + +func (f *fakeAdminKeto) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { + return nil, nil +} + +func (f *fakeAdminKeto) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + return nil, nil +} + +type fakeOverviewAuditRepo struct { + mockAuditRepo + since time.Time + count int64 +} + +func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + f.since = since + return f.count, nil +} + +func TestAdminHandler_GetRPUsageDaily(t *testing.T) { + repo := &fakeRPUsageQueryRepo{ + items: []domain.RPUsageDailyMetric{ + { + Date: "2026-05-06", + TenantID: "tenant-1", + TenantType: domain.TenantTypeCompany, + ClientID: "orgfront", + ClientName: "OrgFront", + LoginRequests: 12, + OtherRequests: 4, + UniqueSubjects: 8, + }, + }, + } + h := &AdminHandler{RPUsageQueries: repo} + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin}) + return c.Next() + }) + app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?days=7&period=week&tenantId=tenant-1", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 7, repo.query.Days) + require.Equal(t, "week", repo.query.Period) + require.Equal(t, "tenant-1", repo.query.TenantID) + + var body struct { + Items []domain.RPUsageDailyMetric `json:"items"` + Days int `json:"days"` + Period string `json:"period"` + TenantID string `json:"tenantId"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, 7, body.Days) + require.Equal(t, "week", body.Period) + require.Equal(t, "tenant-1", body.TenantID) + require.Len(t, body.Items, 1) + require.Equal(t, "orgfront", body.Items[0].ClientID) + require.Equal(t, uint64(12), body.Items[0].LoginRequests) +} + +func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) { + repo := &fakeRPUsageQueryRepo{} + keto := &fakeAdminKeto{allowed: true} + h := &AdminHandler{RPUsageQueries: repo, Keto: keto} + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleTenantAdmin, + }) + return c.Next() + }) + app.Get("/api/v1/admin/rp-usage/daily", h.GetRPUsageDaily) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/rp-usage/daily?tenantId=tenant-allowed", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, "User:user-1", keto.subject) + require.Equal(t, "tenant-allowed", keto.object) + require.Equal(t, "view_rp_usage_stats", keto.relation) + require.Equal(t, "tenant-allowed", repo.query.TenantID) +} + +func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) { + auditRepo := &fakeOverviewAuditRepo{count: 22} + h := &AdminHandler{AuditRepo: auditRepo} + app := fiber.New() + app.Get("/api/v1/admin/stats", h.GetSystemStats) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Contains(t, body, "totalTenants") + require.Contains(t, body, "oidcClients") + require.Contains(t, body, "auditEvents24h") + require.Equal(t, float64(22), body["auditEvents24h"]) + require.Equal(t, time.UTC, auditRepo.since.Location()) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 6f1d625b..8493d2ec 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -29,6 +29,7 @@ import ( "github.com/go-jose/go-jose/v4" josejwt "github.com/go-jose/go-jose/v4/jwt" "github.com/gofiber/fiber/v2" + "github.com/lib/pq" ) const ( @@ -101,6 +102,7 @@ type AuthHandler struct { UserRepo repository.UserRepository ConsentRepo repository.ClientConsentRepository RPUserMetadataRepo repository.RPUserMetadataRepository + RPUsageSink domain.RPUsageEventSink } type signupState struct { @@ -245,6 +247,92 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden } } +func (h *AuthHandler) emitRPUsageAuthorizationGranted(c *fiber.Ctx, consentRequest *domain.HydraConsentRequest, profile *domain.UserProfileResponse, sessionID string, autoAccepted bool, correlationID string) error { + if consentRequest == nil { + return nil + } + return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationGranted, consentRequest.Subject, consentRequest.Client, consentRequest.RequestedScope, profile, sessionID, "hydra_consent", correlationID, domain.JSONMap{ + "auto_accepted": autoAccepted, + "scopes": consentRequest.RequestedScope, + }) +} + +func (h *AuthHandler) emitRPUsageAuthorizationRevoked(c *fiber.Ctx, subject string, clientID string, profile *domain.UserProfileResponse, sessionID string) error { + return h.emitRPUsageEvent(c, domain.RPUsageEventTypeAuthorizationRevoked, subject, domain.HydraClient{ClientID: clientID}, nil, profile, sessionID, "hydra_consent", clientID, domain.JSONMap{}) +} + +func (h *AuthHandler) emitRPUsageEvent(c *fiber.Ctx, eventType string, subject string, client domain.HydraClient, scopes []string, profile *domain.UserProfileResponse, sessionID string, source string, correlationID string, payload domain.JSONMap) error { + if h.RPUsageSink == nil { + return nil + } + clientID := strings.TrimSpace(client.ClientID) + if clientID == "" || strings.TrimSpace(subject) == "" { + return nil + } + tenantID, tenantType := rpUsageTenantFromProfile(profile) + event := domain.RPUsageEvent{ + EventType: eventType, + Subject: strings.TrimSpace(subject), + TenantID: tenantID, + TenantType: tenantType, + ClientID: clientID, + ClientName: strings.TrimSpace(client.ClientName), + SessionID: strings.TrimSpace(sessionID), + Scopes: pq.StringArray(scopes), + Source: source, + CorrelationID: strings.TrimSpace(correlationID), + Payload: payload, + OccurredAt: time.Now(), + } + if event.Payload == nil { + event.Payload = domain.JSONMap{} + } + if event.ClientName != "" { + event.Payload["client_name"] = event.ClientName + } + if tenantID != "" { + event.Payload["tenant_id"] = tenantID + } + if tenantType != "" { + event.Payload["tenant_type"] = tenantType + } + if c != nil { + event.Payload["ip_address"] = c.IP() + event.Payload["user_agent"] = string(c.Request().Header.UserAgent()) + } + ctx := context.Background() + if c != nil && c.UserContext() != nil { + ctx = c.UserContext() + } + return h.RPUsageSink.EmitRPUsageEvent(ctx, event) +} + +func rpUsageTenantFromProfile(profile *domain.UserProfileResponse) (string, string) { + if profile == nil { + return "", "" + } + tenantID := "" + if profile.SessionTenantID != nil { + tenantID = strings.TrimSpace(*profile.SessionTenantID) + } + if tenantID == "" && profile.TenantID != nil { + tenantID = strings.TrimSpace(*profile.TenantID) + } + tenantType := "" + if profile.Tenant != nil { + switch strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) { + case domain.TenantTypeCompany, domain.TenantTypeOrganization: + tenantType = strings.ToUpper(strings.TrimSpace(profile.Tenant.Type)) + if tenantID == "" { + tenantID = strings.TrimSpace(profile.Tenant.ID) + } + case domain.TenantTypeUserGroup, domain.TenantTypePersonal: + return "", "" + } + } + return tenantID, tenantType +} + // --- Signup Flow Handlers --- // CheckEmail - 이메일 사용 가능 여부를 확인합니다. @@ -5323,6 +5411,12 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error { if err != nil || subject == "" { return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } + profile, profileErr := h.resolveCurrentProfile(c) + if (profileErr != nil || profile == nil) && subject != "" { + if fallbackProfile, fallbackErr := h.resolveProfileForSubject(c.Context(), subject); fallbackErr == nil { + profile = fallbackProfile + } + } slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID) @@ -5354,6 +5448,11 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error { }) } + if err := h.emitRPUsageAuthorizationRevoked(c, subject, clientID, profile, h.resolveCurrentSessionID(c)); err != nil { + slog.Error("failed to emit rp usage event for revoked consent", "error", err, "client_id", clientID, "subject", subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } + h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "") return c.Status(fiber.StatusOK).JSON(fiber.Map{ @@ -5434,6 +5533,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err == nil { + if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil { + slog.Error("failed to emit rp usage event for local consent auto-accept", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } return c.JSON(acceptResp) } slog.Error("failed to force auto-accept based on local DB", "error", err) @@ -5516,6 +5619,11 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { }) } + if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, true, challenge); err != nil { + slog.Error("failed to emit rp usage event for skip consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } + slog.Info("Consent skipped and auto-accepted", "subject", consentRequest.Subject, "client", consentRequest.Client.ClientID, "session_id", currentSessionID) return c.JSON(acceptResp) } @@ -5705,6 +5813,11 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { }) } + if err := h.emitRPUsageAuthorizationGranted(c, consentRequest, profile, currentSessionID, false, req.ConsentChallenge); err != nil { + slog.Error("failed to emit rp usage event for accepted consent", "error", err, "client_id", consentRequest.Client.ClientID, "subject", consentRequest.Subject) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to record RP usage event") + } + return c.JSON(acceptResp) } diff --git a/backend/internal/handler/auth_handler_client_test.go b/backend/internal/handler/auth_handler_client_test.go index 6119340f..81b5fb89 100644 --- a/backend/internal/handler/auth_handler_client_test.go +++ b/backend/internal/handler/auth_handler_client_test.go @@ -3,6 +3,7 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "encoding/json" "io" "net/http" @@ -38,12 +39,14 @@ func TestRevokeLinkedRp_Success(t *testing.T) { defer func() { http.DefaultClient = origDefault }() auditRepo := &mockAuditRepo{} + rpUsageSink := &mockRPUsageEventSink{} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, - AuditRepo: auditRepo, + AuditRepo: auditRepo, + RPUsageSink: rpUsageSink, } app := fiber.New() app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) @@ -54,6 +57,16 @@ func TestRevokeLinkedRp_Success(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) + assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Equal(t, "success", auditRepo.logs[0].Status) + auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details) + assert.NoError(t, err) + assert.Equal(t, "app-1", auditDetails["client_id"]) + assert.Equal(t, 1, len(rpUsageSink.events)) + assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType) + assert.Equal(t, "user-123", rpUsageSink.events[0].Subject) + assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID) } func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) { diff --git a/backend/internal/handler/auth_handler_consent_test.go b/backend/internal/handler/auth_handler_consent_test.go index 2d701319..f064b482 100644 --- a/backend/internal/handler/auth_handler_consent_test.go +++ b/backend/internal/handler/auth_handler_consent_test.go @@ -3,6 +3,7 @@ package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "bytes" "context" "encoding/json" @@ -305,6 +306,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { defer func() { http.DefaultClient = origDefault }() consentRepo := &mockConsentRepo{} + rpUsageSink := &mockRPUsageEventSink{} mockKratosAdmin := &MockKratosAdminServiceForConsent{} h := &AuthHandler{ @@ -314,6 +316,7 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { }, KratosAdmin: mockKratosAdmin, ConsentRepo: consentRepo, + RPUsageSink: rpUsageSink, } mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", @@ -332,6 +335,11 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) { var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) assert.Equal(t, "http://rp/cb", body["redirectTo"]) + assert.Equal(t, 1, len(rpUsageSink.events)) + assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType) + assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID) + assert.Equal(t, "challenge-skip", rpUsageSink.events[0].CorrelationID) + assert.Equal(t, true, rpUsageSink.events[0].Payload["auto_accepted"]) } func TestAcceptConsentRequest_Normal(t *testing.T) { @@ -370,6 +378,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { auditRepo := &mockAuditRepo{} consentRepo := &mockConsentRepo{} + rpUsageSink := &mockRPUsageEventSink{} mockKratosAdmin := &MockKratosAdminServiceForConsent{} h := &AuthHandler{ @@ -380,6 +389,7 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { KratosAdmin: mockKratosAdmin, AuditRepo: auditRepo, ConsentRepo: consentRepo, + RPUsageSink: rpUsageSink, } mockKratosAdmin.On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{ ID: "user-123", @@ -402,6 +412,21 @@ func TestAcceptConsentRequest_Normal(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) + assert.Equal(t, "consent.granted", auditRepo.logs[0].EventType) + assert.Equal(t, "user-123", auditRepo.logs[0].UserID) + assert.Equal(t, "success", auditRepo.logs[0].Status) + auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details) + assert.NoError(t, err) + assert.Equal(t, "client-app", auditDetails["client_id"]) + assert.Equal(t, "Test App", auditDetails["client_name"]) + assert.Equal(t, []interface{}{"openid"}, auditDetails["scopes"]) + assert.Equal(t, 1, len(rpUsageSink.events)) + assert.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, rpUsageSink.events[0].EventType) + assert.Equal(t, "user-123", rpUsageSink.events[0].Subject) + assert.Equal(t, "client-app", rpUsageSink.events[0].ClientID) + assert.Equal(t, "Test App", rpUsageSink.events[0].ClientName) + assert.Equal(t, []string{"openid"}, []string(rpUsageSink.events[0].Scopes)) + assert.Equal(t, "hydra_consent", rpUsageSink.events[0].Source) } func TestAcceptConsentRequest_EnforcesMandatoryTenantScope(t *testing.T) { diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index af462748..85020233 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -109,12 +109,29 @@ func (m *mockAuditRepo) CountFailuresSince(ctx context.Context, since time.Time, return 0, nil } +func (m *mockAuditRepo) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + return 0, nil +} + func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { return 0, nil } func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil } +type mockRPUsageEventSink struct { + events []domain.RPUsageEvent + err error +} + +func (m *mockRPUsageEventSink) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { + if m.err != nil { + return m.err + } + m.events = append(m.events, event) + return nil +} + type mockOathkeeperRepo struct { logs []domain.OathkeeperAccessLog } diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go index 0244b64a..25bd1d30 100644 --- a/backend/internal/middleware/audit_middleware_test.go +++ b/backend/internal/middleware/audit_middleware_test.go @@ -40,6 +40,10 @@ func (m *MockAuditRepository) CountFailuresSince(ctx context.Context, since time return 0, nil } +func (m *MockAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + return 0, nil +} + func (m *MockAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { return 0, nil } @@ -73,6 +77,10 @@ func (r *recordingAuditRepository) CountFailuresSince(ctx context.Context, since return 0, nil } +func (r *recordingAuditRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + return 0, nil +} + func (r *recordingAuditRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { return 0, nil } diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index 54ca02a7..29039411 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -3,6 +3,7 @@ package repository import ( "baron-sso-backend/internal/domain" "context" + "encoding/json" "fmt" "time" @@ -77,9 +78,73 @@ func NewClickHouseRepository(host string, port int, user, password, db string) ( return nil, fmt.Errorf("failed to alter table: %w", err) } + if err := ensureRPUsageTables(context.Background(), conn); err != nil { + return nil, fmt.Errorf("failed to create rp usage tables: %w", err) + } + return &ClickHouseRepository{conn: conn}, nil } +func ensureRPUsageTables(ctx context.Context, conn driver.Conn) error { + factQuery := ` + CREATE TABLE IF NOT EXISTS rp_usage_events ( + event_id String, + occurred_at DateTime64(3) DEFAULT now64(3), + event_type String, + subject String, + tenant_id String, + tenant_type String, + client_id String, + client_name String, + session_id String, + scopes Array(String), + source String, + correlation_id String, + payload String + ) ENGINE = MergeTree() + ORDER BY (occurred_at, event_id) + ` + if err := conn.Exec(ctx, factQuery); err != nil { + return err + } + + aggregateQuery := ` + CREATE TABLE IF NOT EXISTS rp_usage_daily_aggregate ( + event_date Date, + tenant_id String, + tenant_type String, + client_id String, + client_name String, + event_type String, + events_count AggregateFunction(count), + unique_subjects AggregateFunction(uniqExact, String) + ) ENGINE = AggregatingMergeTree() + ORDER BY (event_date, tenant_id, client_id, event_type) + ` + if err := conn.Exec(ctx, aggregateQuery); err != nil { + return err + } + + viewQuery := ` + CREATE MATERIALIZED VIEW IF NOT EXISTS rp_usage_daily_aggregate_mv + TO rp_usage_daily_aggregate + AS + SELECT + toDate(occurred_at) AS event_date, + tenant_id, + tenant_type, + client_id, + any(client_name) AS client_name, + event_type, + countState() AS events_count, + uniqExactState(subject) AS unique_subjects + FROM rp_usage_events + WHERE tenant_type IN ('COMPANY', 'ORGANIZATION') + GROUP BY event_date, tenant_id, tenant_type, client_id, event_type + ` + return conn.Exec(ctx, viewQuery) +} + func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -106,6 +171,125 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { ) } +func (r *ClickHouseRepository) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { + if r == nil || r.conn == nil { + return fmt.Errorf("clickhouse connection is nil") + } + if event.OccurredAt.IsZero() { + event.OccurredAt = time.Now() + } + payloadBytes, err := json.Marshal(event.Payload) + if err != nil { + return fmt.Errorf("failed to marshal rp usage payload: %w", err) + } + query := ` + INSERT INTO rp_usage_events ( + event_id, occurred_at, event_type, subject, tenant_id, tenant_type, + client_id, client_name, session_id, scopes, source, correlation_id, payload + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + return r.conn.Exec(ctx, query, + event.ID, + event.OccurredAt, + event.EventType, + event.Subject, + event.TenantID, + event.TenantType, + event.ClientID, + event.ClientName, + event.SessionID, + []string(event.Scopes), + event.Source, + event.CorrelationID, + string(payloadBytes), + ) +} + +func (r *ClickHouseRepository) FindRPUsage(ctx context.Context, rpQuery domain.RPUsageQuery) ([]domain.RPUsageDailyMetric, error) { + if r == nil || r.conn == nil { + return nil, fmt.Errorf("clickhouse connection is nil") + } + days := rpQuery.Days + if days <= 0 || days > 90 { + days = 14 + } + periodExpr := "event_date" + switch rpQuery.Period { + case "week": + periodExpr = "toMonday(event_date)" + case "month": + periodExpr = "toStartOfMonth(event_date)" + case "day", "": + periodExpr = "event_date" + default: + periodExpr = "event_date" + } + + query := fmt.Sprintf(` + SELECT + date, + tenant_id, + tenant_type, + client_id, + any(client_name) AS client_name, + sumIf(events, event_type = ?) AS login_requests, + sumIf(events, event_type != ?) AS other_requests, + max(unique_subjects) AS unique_subjects + FROM ( + SELECT + toString(%s) AS date, + tenant_id, + tenant_type, + client_id, + any(client_name) AS client_name, + event_type, + countMerge(events_count) AS events, + uniqExactMerge(unique_subjects) AS unique_subjects + FROM rp_usage_daily_aggregate + WHERE event_date >= today() - ? + AND tenant_type IN ('COMPANY', 'ORGANIZATION') +`, periodExpr) + args := []any{domain.RPUsageEventTypeAuthorizationGranted, domain.RPUsageEventTypeAuthorizationGranted, days - 1} + if rpQuery.TenantID != "" { + query += " AND tenant_id = ?\n" + args = append(args, rpQuery.TenantID) + } + query += fmt.Sprintf(` + GROUP BY %s, tenant_id, tenant_type, client_id, event_type + ) + GROUP BY date, tenant_id, tenant_type, client_id + ORDER BY date ASC, tenant_id ASC, client_id ASC + `, periodExpr) + rows, err := r.conn.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query rp usage daily aggregate: %w", err) + } + defer rows.Close() + + metrics := make([]domain.RPUsageDailyMetric, 0) + for rows.Next() { + var metric domain.RPUsageDailyMetric + if err := rows.Scan( + &metric.Date, + &metric.TenantID, + &metric.TenantType, + &metric.ClientID, + &metric.ClientName, + &metric.LoginRequests, + &metric.OtherRequests, + &metric.UniqueSubjects, + ); err != nil { + return nil, fmt.Errorf("failed to scan rp usage daily aggregate: %w", err) + } + if metric.ClientName == "" { + metric.ClientName = metric.ClientID + } + metrics = append(metrics, metric) + } + return metrics, nil +} + func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor, tenantID string) ([]domain.AuditLog, error) { if limit <= 0 { limit = 50 @@ -228,6 +412,21 @@ func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since tim return count, nil } +func (r *ClickHouseRepository) CountEventsSince(ctx context.Context, since time.Time) (int64, error) { + sinceUTC := since.UTC().Format("2006-01-02 15:04:05") + query := fmt.Sprintf(` + SELECT count() + FROM audit_logs + WHERE timestamp >= toDateTime('%s') + `, sinceUTC) + var count int64 + err := r.conn.QueryRow(ctx, query).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count audit events: %w", err) + } + return count, nil +} + func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { // We use uniqExact(session_id) to count unique sessions that had success events recently. query := ` diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index c1c08d0c..e8de32c8 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -63,7 +63,7 @@ func TestMain(m *testing.M) { } // Auto-migrate - err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}) + err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}) if err != nil { log.Fatalf("failed to migrate database: %s", err) } diff --git a/backend/internal/repository/rp_usage_outbox_repository.go b/backend/internal/repository/rp_usage_outbox_repository.go new file mode 100644 index 00000000..58129a22 --- /dev/null +++ b/backend/internal/repository/rp_usage_outbox_repository.go @@ -0,0 +1,91 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type RPUsageOutboxRepository interface { + Create(ctx context.Context, event *domain.RPUsageEvent) error + ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) + MarkProcessing(ctx context.Context, id string) error + MarkProcessed(ctx context.Context, id string) error + MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error +} + +type rpUsageOutboxRepository struct { + db *gorm.DB +} + +func NewRPUsageOutboxRepository(db *gorm.DB) RPUsageOutboxRepository { + return &rpUsageOutboxRepository{db: db} +} + +func (r *rpUsageOutboxRepository) Create(ctx context.Context, event *domain.RPUsageEvent) error { + if event.Payload == nil { + event.Payload = domain.JSONMap{} + } + if event.Status == "" { + event.Status = domain.RPUsageOutboxStatusPending + } + if event.OccurredAt.IsZero() { + event.OccurredAt = time.Now() + } + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dedupe_key"}}, + DoNothing: true, + }).Create(event).Error +} + +func (r *rpUsageOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + var rows []domain.RPUsageEvent + err := r.db.WithContext(ctx). + Where("status = ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?)", domain.RPUsageOutboxStatusPending, time.Now()). + Order("occurred_at asc, created_at asc"). + Limit(limit). + Find(&rows).Error + return rows, err +} + +func (r *rpUsageOutboxRepository) MarkProcessing(ctx context.Context, id string) error { + return r.db.WithContext(ctx). + Model(&domain.RPUsageEvent{}). + Where("id = ? AND status = ?", id, domain.RPUsageOutboxStatusPending). + Updates(map[string]any{ + "status": domain.RPUsageOutboxStatusProcessing, + "updated_at": time.Now(), + }).Error +} + +func (r *rpUsageOutboxRepository) MarkProcessed(ctx context.Context, id string) error { + now := time.Now() + return r.db.WithContext(ctx). + Model(&domain.RPUsageEvent{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": domain.RPUsageOutboxStatusProcessed, + "last_error": "", + "processed_at": &now, + "updated_at": now, + }).Error +} + +func (r *rpUsageOutboxRepository) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error { + return r.db.WithContext(ctx). + Model(&domain.RPUsageEvent{}). + Where("id = ?", id). + Updates(map[string]any{ + "status": domain.RPUsageOutboxStatusFailed, + "retry_count": gorm.Expr("retry_count + 1"), + "last_error": message, + "next_attempt_at": &nextAttemptAt, + "updated_at": time.Now(), + }).Error +} diff --git a/backend/internal/service/rp_usage_event_emitter.go b/backend/internal/service/rp_usage_event_emitter.go new file mode 100644 index 00000000..73c0f8f3 --- /dev/null +++ b/backend/internal/service/rp_usage_event_emitter.go @@ -0,0 +1,67 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" +) + +type RPUsageEventEmitter struct { + repo repository.RPUsageOutboxRepository +} + +func NewRPUsageEventEmitter(repo repository.RPUsageOutboxRepository) *RPUsageEventEmitter { + return &RPUsageEventEmitter{repo: repo} +} + +func (e *RPUsageEventEmitter) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { + if e == nil || e.repo == nil { + return nil + } + event.EventType = strings.TrimSpace(event.EventType) + event.Subject = strings.TrimSpace(event.Subject) + event.ClientID = strings.TrimSpace(event.ClientID) + event.Source = strings.TrimSpace(event.Source) + event.CorrelationID = strings.TrimSpace(event.CorrelationID) + if event.EventType == "" { + return fmt.Errorf("rp usage event type is required") + } + if event.Subject == "" { + return fmt.Errorf("rp usage subject is required") + } + if event.ClientID == "" { + return fmt.Errorf("rp usage client_id is required") + } + if event.Source == "" { + event.Source = "backend" + } + if event.OccurredAt.IsZero() { + event.OccurredAt = time.Now() + } + if event.DedupeKey == "" { + event.DedupeKey = buildRPUsageDedupeKey(event) + } + if event.Payload == nil { + event.Payload = domain.JSONMap{} + } + return e.repo.Create(ctx, &event) +} + +func buildRPUsageDedupeKey(event domain.RPUsageEvent) string { + raw := strings.Join([]string{ + event.EventType, + event.Subject, + event.ClientID, + event.SessionID, + event.Source, + event.CorrelationID, + event.OccurredAt.UTC().Format("2006-01-02T15:04:05.000Z"), + }, "|") + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/service/rp_usage_event_emitter_test.go b/backend/internal/service/rp_usage_event_emitter_test.go new file mode 100644 index 00000000..976d1dc2 --- /dev/null +++ b/backend/internal/service/rp_usage_event_emitter_test.go @@ -0,0 +1,132 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type fakeRPUsageOutboxRepo struct { + created []domain.RPUsageEvent + ready []domain.RPUsageEvent + processing []string + processed []string + failed []string + createErr error + projectErr error +} + +func (f *fakeRPUsageOutboxRepo) Create(ctx context.Context, event *domain.RPUsageEvent) error { + if f.createErr != nil { + return f.createErr + } + f.created = append(f.created, *event) + return nil +} + +func (f *fakeRPUsageOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) { + return f.ready, nil +} + +func (f *fakeRPUsageOutboxRepo) MarkProcessing(ctx context.Context, id string) error { + f.processing = append(f.processing, id) + return nil +} + +func (f *fakeRPUsageOutboxRepo) MarkProcessed(ctx context.Context, id string) error { + f.processed = append(f.processed, id) + return nil +} + +func (f *fakeRPUsageOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error { + f.failed = append(f.failed, id) + return nil +} + +type fakeRPUsageProjectionRepo struct { + created []domain.RPUsageEvent + err error +} + +func (f *fakeRPUsageProjectionRepo) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { + if f.err != nil { + return f.err + } + f.created = append(f.created, event) + return nil +} + +func TestRPUsageEventEmitterRequiresCanonicalFields(t *testing.T) { + repo := &fakeRPUsageOutboxRepo{} + emitter := NewRPUsageEventEmitter(repo) + + err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{ + EventType: domain.RPUsageEventTypeAuthorizationGranted, + ClientID: "client-app", + }) + + require.Error(t, err) + require.Empty(t, repo.created) +} + +func TestRPUsageEventEmitterCreatesPendingOutboxEvent(t *testing.T) { + repo := &fakeRPUsageOutboxRepo{} + emitter := NewRPUsageEventEmitter(repo) + + err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{ + EventType: domain.RPUsageEventTypeAuthorizationGranted, + Subject: "user-123", + ClientID: "client-app", + Source: "hydra_consent", + CorrelationID: "challenge-1", + }) + + require.NoError(t, err) + require.Len(t, repo.created, 1) + require.NotEmpty(t, repo.created[0].DedupeKey) + require.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, repo.created[0].EventType) + require.Equal(t, "hydra_consent", repo.created[0].Source) +} + +func TestRPUsageProjectorWorkerMarksProcessedAfterProjection(t *testing.T) { + outbox := &fakeRPUsageOutboxRepo{ + ready: []domain.RPUsageEvent{{ + ID: "event-1", + EventType: domain.RPUsageEventTypeAuthorizationGranted, + Subject: "user-123", + ClientID: "client-app", + }}, + } + projection := &fakeRPUsageProjectionRepo{} + worker := NewRPUsageProjectorWorker(outbox, projection) + + worker.processOnce(context.Background()) + + require.Equal(t, []string{"event-1"}, outbox.processing) + require.Equal(t, []string{"event-1"}, outbox.processed) + require.Empty(t, outbox.failed) + require.Len(t, projection.created, 1) +} + +func TestRPUsageProjectorWorkerMarksFailedWhenProjectionFails(t *testing.T) { + outbox := &fakeRPUsageOutboxRepo{ + ready: []domain.RPUsageEvent{{ + ID: "event-1", + EventType: domain.RPUsageEventTypeAuthorizationGranted, + Subject: "user-123", + ClientID: "client-app", + }}, + } + projection := &fakeRPUsageProjectionRepo{err: errors.New("clickhouse unavailable")} + worker := NewRPUsageProjectorWorker(outbox, projection) + + worker.processOnce(context.Background()) + + require.Equal(t, []string{"event-1"}, outbox.processing) + require.Empty(t, outbox.processed) + require.Equal(t, []string{"event-1"}, outbox.failed) +} diff --git a/backend/internal/service/rp_usage_projector_worker.go b/backend/internal/service/rp_usage_projector_worker.go new file mode 100644 index 00000000..286f4831 --- /dev/null +++ b/backend/internal/service/rp_usage_projector_worker.go @@ -0,0 +1,82 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "log/slog" + "time" +) + +type RPUsageProjectorWorker struct { + outbox repository.RPUsageOutboxRepository + projection domain.RPUsageProjectionRepository + interval time.Duration + batchSize int +} + +func NewRPUsageProjectorWorker(outbox repository.RPUsageOutboxRepository, projection domain.RPUsageProjectionRepository) *RPUsageProjectorWorker { + return &RPUsageProjectorWorker{ + outbox: outbox, + projection: projection, + interval: 5 * time.Second, + batchSize: 50, + } +} + +func (w *RPUsageProjectorWorker) Start(ctx context.Context) { + if w == nil || w.outbox == nil || w.projection == nil { + return + } + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + default: + w.processOnce(ctx) + } + + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +func (w *RPUsageProjectorWorker) processOnce(ctx context.Context) { + events, err := w.outbox.ListReady(ctx, w.batchSize) + if err != nil { + slog.Warn("failed to list rp usage outbox", "error", err) + return + } + for _, event := range events { + if err := w.outbox.MarkProcessing(ctx, event.ID); err != nil { + slog.Warn("failed to mark rp usage event processing", "event_id", event.ID, "error", err) + continue + } + if err := w.projection.CreateRPUsageEvent(ctx, event); err != nil { + nextAttempt := time.Now().Add(backoffDuration(event.RetryCount)) + _ = w.outbox.MarkFailed(ctx, event.ID, err.Error(), nextAttempt) + slog.Warn("failed to project rp usage event", "event_id", event.ID, "error", err) + continue + } + if err := w.outbox.MarkProcessed(ctx, event.ID); err != nil { + slog.Warn("failed to mark rp usage event processed", "event_id", event.ID, "error", err) + } + } +} + +func backoffDuration(retryCount int) time.Duration { + if retryCount < 0 { + retryCount = 0 + } + delay := time.Duration(retryCount+1) * time.Minute + if delay > 30*time.Minute { + return 30 * time.Minute + } + return delay +} diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml index 83fd6a90..0a32fddf 100644 --- a/docker/ory/vector/vector.toml +++ b/docker/ory/vector/vector.toml @@ -8,55 +8,114 @@ inputs = ["oathkeeper_file"] source = ''' raw = to_string(.message) ?? "" - parsed = parse_json(raw) ?? {} + parsed = object!(parse_json(raw) ?? {}) request_method = to_string(get(parsed, ["request", "method"]) ?? "") ?? "" + if request_method == "" { request_method = to_string(get(parsed, ["http_request", "method"]) ?? "") ?? "" } request_path = to_string(get(parsed, ["request", "path"]) ?? "") ?? "" + if request_path == "" { request_path = to_string(get(parsed, ["http_request", "path"]) ?? "") ?? "" } request_url = to_string(get(parsed, ["request", "url"]) ?? "") ?? "" + if request_url == "" { request_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" } request_host = to_string(get(parsed, ["request", "host"]) ?? "") ?? "" + if request_host == "" { request_host = to_string(get(parsed, ["http_request", "host"]) ?? "") ?? "" } request_scheme = to_string(get(parsed, ["request", "scheme"]) ?? "") ?? "" + if request_scheme == "" { request_scheme = to_string(get(parsed, ["http_request", "scheme"]) ?? "") ?? "" } request_query = to_string(get(parsed, ["request", "query"]) ?? "") ?? "" - response_status = get(parsed, ["response", "status"]) ?? 0 + if request_query == "" { request_query = to_string(get(parsed, ["http_request", "query"]) ?? "") ?? "" } + response_status = to_int(get(parsed, ["response", "status"]) ?? 0) ?? 0 + if response_status == 0 { response_status = to_int(get(parsed, ["http_response", "status"]) ?? 0) ?? 0 } + response_size = to_int(get(parsed, ["response", "size"]) ?? 0) ?? 0 + if response_size == 0 { response_size = to_int(get(parsed, ["http_response", "size"]) ?? 0) ?? 0 } + response_took = to_int(get(parsed, ["response", "took"]) ?? 0) ?? 0 + if response_took == 0 { response_took = to_int(get(parsed, ["http_response", "took"]) ?? 0) ?? 0 } identity_id = to_string(get(parsed, ["identity", "id"]) ?? "") ?? "" - headers = get(parsed, ["headers"]) ?? {} + if identity_id == "" { identity_id = to_string(get(parsed, ["subject"]) ?? "") ?? "" } + headers = object(get(parsed, ["headers"]) ?? {}) ?? {} + if length(headers) == 0 { headers = object(get(parsed, ["http_request", "headers"]) ?? {}) ?? {} } user_agent = to_string(get(headers, ["User-Agent"]) ?? "") ?? "" + if user_agent == "" { user_agent = to_string(get(headers, ["user-agent"]) ?? "") ?? "" } referer = to_string(get(headers, ["Referer"]) ?? "") ?? "" + if referer == "" { referer = to_string(get(headers, ["referer"]) ?? "") ?? "" } rule_id = to_string(get(parsed, ["rule", "id"]) ?? "") ?? "" + if rule_id == "" { rule_id = to_string(get(parsed, ["rule_id"]) ?? "") ?? "" } upstream_url = to_string(get(parsed, ["upstream", "url"]) ?? "") ?? "" + if upstream_url == "" { upstream_url = to_string(get(parsed, ["http_url"]) ?? "") ?? "" } client_id = to_string(get(parsed, ["client", "id"]) ?? "") ?? "" parent_session_id = to_string(get(parsed, ["extra", "parent_session_id"]) ?? "") ?? "" parsed_url = parse_url(request_url) ?? {} query_params = get(parsed_url, ["query"]) ?? {} + url_path = to_string(get(parsed_url, ["path"]) ?? "") ?? "" + parsed_request_query = parse_url("http://localhost/?" + request_query) ?? {} + request_query_params = get(parsed_request_query, ["query"]) ?? {} event_path = to_string(parsed.path) ?? to_string(parsed.http_path) ?? "" if event_path == "" { event_path = request_path } + if event_path == "" { event_path = url_path } if event_path == "" { event_path = request_url } event_client_id = to_string(parsed.client_id) ?? "" if event_client_id == "" { event_client_id = client_id } if event_client_id == "" { event_client_id = to_string(get(query_params, ["client_id"]) ?? "") ?? "" } if event_client_id == "" { event_client_id = to_string(get(query_params, ["clientId"]) ?? "") ?? "" } + if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["client_id"]) ?? "") ?? "" } + if event_client_id == "" { event_client_id = to_string(get(request_query_params, ["clientId"]) ?? "") ?? "" } + event_latency_ms = to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? 0 + if event_latency_ms == 0 && response_took != 0 { + event_latency_ms = to_int(to_float(response_took) / 1000000.0) + } + event_client_ip = to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "" + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Real-Ip"]) ?? "") ?? "" } + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-real-ip"]) ?? "") ?? "" } + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["X-Forwarded-For"]) ?? "") ?? "" } + if event_client_ip == "" { event_client_ip = to_string(get(headers, ["x-forwarded-for"]) ?? "") ?? "" } + event_decision = to_string(parsed.decision) ?? to_string(parsed.result) ?? "" + if event_decision == "" && exists(parsed.granted) { + if parsed.granted == true { + event_decision = "granted" + } else { + event_decision = "denied" + } + } + event_status = to_int(get(parsed, ["status"]) ?? 0) ?? 0 + if event_status == 0 { event_status = to_int(get(parsed, ["status_code"]) ?? 0) ?? 0 } + if event_status == 0 { event_status = response_status } + event_bytes_out = to_int(get(parsed, ["bytes_out"]) ?? 0) ?? 0 + if event_bytes_out == 0 { event_bytes_out = to_int(get(parsed, ["response_bytes"]) ?? 0) ?? 0 } + if event_bytes_out == 0 { event_bytes_out = response_size } + event_method = to_string(get(parsed, ["method"]) ?? "") ?? "" + if event_method == "" { event_method = to_string(get(parsed, ["http_method"]) ?? "") ?? "" } + if event_method == "" { event_method = request_method } + event_host = to_string(get(parsed, ["host"]) ?? "") ?? "" + if event_host == "" { event_host = to_string(get(parsed, ["http_host"]) ?? "") ?? "" } + if event_host == "" { event_host = request_host } + event_scheme = to_string(get(parsed, ["scheme"]) ?? "") ?? "" + if event_scheme == "" { event_scheme = request_scheme } + event_query = to_string(get(parsed, ["query"]) ?? "") ?? "" + if event_query == "" { event_query = request_query } + event_user_agent = to_string(get(parsed, ["user_agent"]) ?? "") ?? "" + if event_user_agent == "" { event_user_agent = to_string(get(parsed, ["http_user_agent"]) ?? "") ?? "" } + if event_user_agent == "" { event_user_agent = user_agent } . = { "request_id": to_string(parsed.request_id) ?? to_string(parsed.req_id) ?? "", - "method": to_string(parsed.method) ?? to_string(parsed.http_method) ?? request_method, + "method": event_method, "path": event_path, - "status": to_int(parsed.status) ?? to_int(parsed.status_code) ?? to_int(response_status) ?? 0, - "latency_ms": to_int(parsed.latency_ms) ?? to_int(parsed.duration_ms) ?? to_int(parsed.took) ?? 0, + "status": event_status, + "latency_ms": event_latency_ms, "client_id": event_client_id, "rp": to_string(parsed.rp) ?? "", "action": to_string(parsed.action) ?? "", "target": to_string(parsed.target) ?? "", "rule_id": to_string(parsed.rule_id) ?? rule_id, - "host": to_string(parsed.host) ?? request_host, - "scheme": to_string(parsed.scheme) ?? request_scheme, - "query": to_string(parsed.query) ?? request_query, + "host": event_host, + "scheme": event_scheme, + "query": event_query, "upstream_url": to_string(parsed.upstream_url) ?? upstream_url, "subject": to_string(parsed.subject) ?? identity_id, "parent_session_id": to_string(parsed.parent_session_id) ?? parent_session_id, - "client_ip": to_string(parsed.client_ip) ?? to_string(parsed.remote_ip) ?? to_string(parsed.ip) ?? "", - "user_agent": to_string(parsed.user_agent) ?? user_agent, + "client_ip": event_client_ip, + "user_agent": event_user_agent, "referer": referer, - "decision": to_string(parsed.decision) ?? to_string(parsed.result) ?? "", + "decision": event_decision, "bytes_in": to_int(parsed.bytes_in) ?? to_int(parsed.request_bytes) ?? 0, - "bytes_out": to_int(parsed.bytes_out) ?? to_int(parsed.response_bytes) ?? 0, + "bytes_out": event_bytes_out, "trace_id": to_string(parsed.trace_id) ?? "", "span_id": to_string(parsed.span_id) ?? "", "raw": raw @@ -73,3 +132,52 @@ auth.strategy = "basic" auth.user = "${ORY_CLICKHOUSE_USER}" auth.password = "${ORY_CLICKHOUSE_PASSWORD}" + +[[tests]] + name = "parses_oathkeeper_v26_completed_request" + + [[tests.inputs]] + insert_at = "oathkeeper_parse" + type = "log" + + [tests.inputs.log_fields] + message = '{"http_request":{"headers":{"user-agent":"Mozilla/5.0","referer":"http://localhost:5173/","x-real-ip":"172.19.0.1"},"host":"localhost","method":"GET","path":"/oauth2/auth","query":"client_id=orgfront&response_type=code","remote":"172.23.0.2:56744","scheme":"http"},"http_response":{"status":302,"size":1339,"took":4854092},"http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback","level":"info","msg":"completed handling request","subject":"","time":"2026-05-06T01:40:51.46074548Z"}' + + [[tests.outputs]] + extract_from = "oathkeeper_parse" + + [[tests.outputs.conditions]] + type = "vrl" + source = ''' + assert_eq!(.method, "GET") + assert_eq!(.path, "/oauth2/auth") + assert_eq!(.status, 302) + assert_eq!(.client_id, "orgfront") + assert_eq!(.host, "localhost") + assert_eq!(.scheme, "http") + assert_eq!(.user_agent, "Mozilla/5.0") + assert_eq!(.referer, "http://localhost:5173/") + ''' + +[[tests]] + name = "parses_oathkeeper_v26_granted_request" + + [[tests.inputs]] + insert_at = "oathkeeper_parse" + type = "log" + + [tests.inputs.log_fields] + message = '{"audience":"application","granted":true,"http_host":"hydra:4444","http_method":"GET","http_url":"http://hydra:4444/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code","http_user_agent":"curl/8.10.1","level":"info","msg":"Access request granted","service_name":"ORY Oathkeeper","service_version":"v26.2.0","subject":"","time":"2026-05-06T01:52:25.431Z"}' + + [[tests.outputs]] + extract_from = "oathkeeper_parse" + + [[tests.outputs.conditions]] + type = "vrl" + source = ''' + assert_eq!(.method, "GET") + assert_eq!(.path, "/oauth2/auth") + assert_eq!(.status, 0) + assert_eq!(.client_id, "orgfront") + assert_eq!(.decision, "granted") + ''' diff --git a/test/oathkeeper_access_log_e2e_test.sh b/test/oathkeeper_access_log_e2e_test.sh index 14482b7c..c76768bd 100755 --- a/test/oathkeeper_access_log_e2e_test.sh +++ b/test/oathkeeper_access_log_e2e_test.sh @@ -58,3 +58,66 @@ if (( after_rows <= before_rows )); then docker logs --tail 100 ory_vector >&2 || true exit 1 fi + +before_auth_ts="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query "SELECT now64(3)")" +auth_status="$(docker run --rm --network public_net curlimages/curl:8.10.1 \ + -sS -o /dev/null -w '%{http_code}' \ + 'http://ory_oathkeeper:4455/oauth2/auth?client_id=orgfront&redirect_uri=http%3A%2F%2Flocalhost%3A5175%2Fauth%2Fcallback&response_type=code&scope=openid&state=access-log-e2e&code_challenge=accessloge2e&code_challenge_method=S256')" + +if [[ "$auth_status" != "302" ]]; then + echo "ERROR: expected Oathkeeper OIDC auth request to return 302, got: $auth_status" >&2 + exit 1 +fi + +deadline=$((SECONDS + 30)) +completed_rows=0 +granted_rows=0 +while (( SECONDS < deadline )); do + completed_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT count() + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + AND method = 'GET' + AND path = '/oauth2/auth' + AND status = 302 + ")" + granted_rows="$(docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT count() + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + AND method = 'GET' + AND path = '/oauth2/auth' + AND client_id = 'orgfront' + AND decision = 'granted' + ")" + if (( completed_rows > 0 && granted_rows > 0 )); then + break + fi + sleep 2 +done + +if (( completed_rows <= 0 )); then + echo "ERROR: Oathkeeper completed request log did not preserve method/path/status." >&2 + docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT timestamp, method, path, status, client_id, decision + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + ORDER BY timestamp DESC + LIMIT 20 + FORMAT Vertical + " >&2 || true + exit 1 +fi + +if (( granted_rows <= 0 )); then + echo "ERROR: Oathkeeper granted request log did not preserve client_id." >&2 + docker exec ory_clickhouse clickhouse-client --user "${ORY_CLICKHOUSE_USER:-ory}" --password "${ORY_CLICKHOUSE_PASSWORD:-orypass}" --query " + SELECT timestamp, method, path, status, client_id, decision + FROM ory.oathkeeper_access_logs + WHERE timestamp >= toDateTime64('$before_auth_ts', 3) + ORDER BY timestamp DESC + LIMIT 20 + FORMAT Vertical + " >&2 || true + exit 1 +fi From 45a14163bf6351a888fa8eac79b1a93816950163 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 7 May 2026 10:27:31 +0900 Subject: [PATCH 03/15] =?UTF-8?q?ory=EC=8A=A4=ED=83=9D=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=97=85=20=EB=B0=8F=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9URL=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 19 +- .gitea/workflows/staging_code_pull.yml | 12 +- Makefile | 6 +- README.md | 4 +- compose.ory.yaml | 157 +-- deploy/create-instance.sh | 12 + deploy/templates/.env.template | 22 + deploy/templates/docker-compose.yaml | 228 ++++- deploy/templates/gateway/nginx.conf | 1 - deploy/templates/ory/kratos/kratos.yml | 62 +- deploy/templates/ory/oathkeeper/rules.json | 150 ++- docker/compose.ory.yaml | 120 +-- docker/ory/kratos/kratos.yml | 60 +- docker/ory/oathkeeper/rules.active.json | 28 +- docker/ory/oathkeeper/rules.json | 28 +- docker/ory/oathkeeper/rules.prod.json | 103 +- docker/ory/oathkeeper/rules.stage.json | 28 +- docker/staging_pull_compose.template.yaml | 962 +++++++++--------- docs/devfront_auth_flow_explanation.md | 8 +- ...oidc_redirect_mapping_validation_policy.md | 41 +- docs/ory-usage.md | 48 +- gateway/nginx.conf | 1 - scripts/auth_config.sh | 17 +- test/make_dev_targets_test.sh | 35 + test/ory_v26_compose_policy_test.sh | 210 ++++ 25 files changed, 1583 insertions(+), 779 deletions(-) diff --git a/.env.sample b/.env.sample index 6d647b2d..2cc41e84 100644 --- a/.env.sample +++ b/.env.sample @@ -76,20 +76,20 @@ HYDRA_DB=ory_hydra KETO_DB=ory_keto # Ory Kratos Configuration -KRATOS_VERSION=v25.4.0-distroless +KRATOS_VERSION=v26.2.0-distroless # KRATOS_PUBLIC_PORT=4433 # Internal only # KRATOS_ADMINFRONT_PORT=4434 # Internal only -KRATOS_UI_NODE_VERSION=v25.4.0 +KRATOS_UI_NODE_VERSION=v26.2.0 # KRATOS_UI_PORT=4455 # Internal only # Ory Hydra Configuration -HYDRA_VERSION=v25.4.0-distroless +HYDRA_VERSION=v26.2.0-distroless # HYDRA_PUBLIC_PORT=4441 # Internal only # HYDRA_ADMINFRONT_PORT=4445 # Internal only # Ory Keto Configuration -KETO_VERSION=v25.4.0-distroless +KETO_VERSION=v26.2.0-distroless # KETO_READ_PORT=4466 # Internal only # KETO_WRITE_PORT=4467 # Internal only KETO_READ_URL=http://keto:4466 @@ -109,16 +109,21 @@ KRATOS_UI_URL=http://localhost:5000 HYDRA_ADMIN_URL=http://hydra:4445 # Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다. HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc +# 선택: Hydra 화면 핸드오프 URL을 USERFRONT_URL 기준 기본값과 다르게 둘 때만 설정합니다. +# HYDRA_LOGIN_URL=https://sso.hmac.kr/login +# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent +# HYDRA_ERROR_URL=https://sso.hmac.kr/error # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. KRATOS_ALLOWED_RETURN_URLS_EXTRA=[] +KRATOS_ALLOWED_RETURN_URLS_JSON=["http://localhost:5000","http://localhost:5000/","https://sso.hmac.kr","https://sso.hmac.kr/","https://sso.hmac.kr/ko","https://sso.hmac.kr/ko/","https://sso.hmac.kr/en","https://sso.hmac.kr/en/","https://sso.hmac.kr/auth/callback","https://sso.hmac.kr/ko/auth/callback","https://sso.hmac.kr/en/auth/callback","http://localhost:5173/auth/callback","http://localhost:5174/auth/callback","http://localhost:5175/auth/callback","https://sso.hmac.kr/orgfront/auth/callback"] # Oathkeeper JWKS (내부 통신용) JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json # Oathkeeper 실행 사용자/프로브 설정 -OATHKEEPER_VERSION=v25.4.0 +OATHKEEPER_VERSION=v26.2.0 OATHKEEPER_UID=1001 OATHKEEPER_GID=1001 OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready @@ -140,5 +145,5 @@ VITE_OIDC_CLIENT_ID=devfront VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc DEVFRONT_URL=http://localhost:5174 DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback -ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback -VITE_ORGCHART_URL= \ No newline at end of file +ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback +VITE_ORGCHART_URL= diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 2abd5158..8e5f2f8a 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -124,11 +124,13 @@ jobs: CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }} CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }} - # Frontend OIDC configs for Staging - VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc - ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback - DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback - ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback + # Frontend/Ory URL configs for Staging + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }} + DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }} + ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }} + KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }} + KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }} # OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} EOF diff --git a/Makefile b/Makefile index 0c688341..f4bd4fa0 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ ifneq (,$(wildcard ./.env)) COMPOSE_DROP_ENV_ARGS += --env-file .env endif -.PHONY: build-auth-config validate-auth-config verify-auth-config up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app +.PHONY: build-auth-config validate-auth-config verify-auth-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app # --- 인증 설정 빌드/검증 --- build-auth-config: @@ -47,6 +47,8 @@ verify-auth-config: validate-auth-config # --- 기본 실행 --- # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) +up: up-all + up-all: ensure-networks validate-auth-config @echo "Starting ALL stacks (infra + ory + app)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d @@ -61,7 +63,7 @@ up-ory: ensure-networks validate-auth-config docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d up-app: ensure-networks validate-auth-config - @echo "Starting App stack (backend/userfront/adminfront/devfront)..." + @echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d up-backend: ensure-networks validate-auth-config diff --git a/README.md b/README.md index 776bb0bc..dd0385bb 100644 --- a/README.md +++ b/README.md @@ -395,11 +395,13 @@ USERFRONT_URL=https://sso.example.com - `KRATOS_BROWSER_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/auth` - `KRATOS_UI_URL`: UserFront UI URL (로컬 예: `http://localhost:5000`) - `ADMINFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5173/auth/callback`) -- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/callback`) +- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/auth/callback`) - 주의: callback URL 끝에 `/`가 붙으면 `make validate-auth-config`에서 실패 처리됩니다. - `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택) - 빈값: `[]` - 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback` +- `KRATOS_ALLOWED_RETURN_URLS_JSON`: stage/prod에서 권장하는 전체 허용 return URL 목록 + - 공개 도메인, `/ko`, `/en`, `/auth/callback`, `/ko/auth/callback`, `/en/auth/callback`, 각 front callback을 포함해야 합니다. - `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`) - 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용 - 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집 diff --git a/compose.ory.yaml b/compose.ory.yaml index 5b6faede..99213589 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -23,20 +23,20 @@ services: # --- Kratos --- kratos-migrate: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["http://localhost:5000","http://localhost:5000/"]} - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - ./docker/ory/kratos:/etc/config/kratos command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes @@ -47,22 +47,22 @@ services: - ory-net kratos: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} container_name: ory_kratos environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} - - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["http://localhost:5000","http://localhost:5000/"]} - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - ./docker/ory/kratos:/etc/config/kratos command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier @@ -75,7 +75,7 @@ services: # --- Hydra --- hydra-migrate: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 command: migrate sql up -e --yes @@ -86,14 +86,14 @@ services: - ory-net hydra: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} container_name: ory_hydra environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc - - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login - - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent - - URLS_ERROR=${USERFRONT_URL:-http://localhost:5000}/error + - URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL} + - URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login} + - URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent} + - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} volumes: - ./docker/ory/hydra:/etc/config/hydra @@ -107,7 +107,7 @@ services: # --- Keto --- keto-migrate: - image: oryd/keto:${KETO_VERSION:-v25.4.0} + image: oryd/keto:${KETO_VERSION:-v26.2.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: @@ -120,7 +120,7 @@ services: - ory-net keto: - image: oryd/keto:${KETO_VERSION:-v25.4.0} + image: oryd/keto:${KETO_VERSION:-v26.2.0} container_name: ory_keto environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 @@ -136,14 +136,19 @@ services: # --- Oathkeeper --- oathkeeper_logs_init: image: alpine:latest - command: ["sh", "-c", "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper"] + command: + [ + "sh", + "-c", + "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper", + ] volumes: - oathkeeper_logs:/var/log/oathkeeper networks: - ory-net oathkeeper: - image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0} + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0} container_name: ory_oathkeeper user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" ports: @@ -220,56 +225,56 @@ services: - /bin/sh - -ec - | - apk add --no-cache curl tar - HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" - HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" - HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" - curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" - tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra - rm /tmp/hydra.tar.gz + apk add --no-cache curl tar + HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" + HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" + HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" + curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" + tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra + rm /tmp/hydra.tar.gz - hydra delete oauth2-client --endpoint http://hydra:4445 adminfront >/dev/null 2>&1 || true - hydra delete oauth2-client --endpoint http://hydra:4445 devfront >/dev/null 2>&1 || true - hydra delete oauth2-client --endpoint http://hydra:4445 orgfront >/dev/null 2>&1 || true - hydra delete oauth2-client --endpoint http://hydra:4445 ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} >/dev/null 2>&1 || true + hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true + hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true + hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true + hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" >/dev/null 2>&1 || true - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id adminfront \ - --name "AdminFront" \ + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id adminfront \ + --name "AdminFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri ${ADMINFRONT_CALLBACK_URLS} + + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id devfront \ + --name "DevFront" \ --grant-type authorization_code,refresh_token \ --response-type code \ --scope openid,offline_access,profile,email \ --token-endpoint-auth-method none \ - --redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback} + --redirect-uri ${DEVFRONT_CALLBACK_URLS} - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id devfront \ - --name "DevFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback} + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id orgfront \ + --name "OrgFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri ${ORGFRONT_CALLBACK_URLS} - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id orgfront \ - --name "OrgFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${ORGFRONT_CALLBACK_URLS:-http://localhost:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback} - - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} \ - --secret ${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} \ - --grant-type client_credentials \ - --response-type token \ - --scope openid,offline_access,profile,email + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ + --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ + --grant-type client_credentials \ + --response-type token \ + --scope openid,offline_access,profile,email depends_on: ory_stack_check: condition: service_completed_successfully diff --git a/deploy/create-instance.sh b/deploy/create-instance.sh index 9ff61865..ecbe46e8 100644 --- a/deploy/create-instance.sh +++ b/deploy/create-instance.sh @@ -18,11 +18,15 @@ echo "🚀 Creating instance: ${INSTANCE_NAME} (Port Prefix: ${PORT_PREFIX}xxx)" # 1. 폴더 구조 생성 mkdir -p "${TARGET_DIR}/gateway" +mkdir -p "${TARGET_DIR}/ory/init-db" mkdir -p "${TARGET_DIR}/ory/kratos" +mkdir -p "${TARGET_DIR}/ory/hydra" +mkdir -p "${TARGET_DIR}/ory/keto" mkdir -p "${TARGET_DIR}/ory/oathkeeper" mkdir -p "${TARGET_DIR}/userfront" mkdir -p "${TARGET_DIR}/adminfront" mkdir -p "${TARGET_DIR}/devfront" +mkdir -p "${TARGET_DIR}/orgfront" # 2. .env 생성 및 변수 로드 sed "s/{{INSTANCE_NAME}}/${INSTANCE_NAME}/g; s/{{PORT_PREFIX}}/${PORT_PREFIX}/g" \ @@ -34,6 +38,7 @@ USERFRONT_PORT="${PORT_PREFIX}500" DOMAIN_SUFFIX=$(grep "DOMAIN_SUFFIX=" "${TARGET_DIR}/.env" | cut -d'=' -f2 | tr -d '\r') ADMINFRONT_DOMAIN="${INSTANCE_NAME}-admin.${DOMAIN_SUFFIX}" DEVFRONT_DOMAIN="${INSTANCE_NAME}-dev.${DOMAIN_SUFFIX}" +ORGFRONT_DOMAIN="${INSTANCE_NAME}-org.${DOMAIN_SUFFIX}" # 3. Docker Compose & Config 복사 및 치환 cp "${BASE_DIR}/templates/docker-compose.yaml" "${TARGET_DIR}/" @@ -55,15 +60,22 @@ sed "s/{{ADMINFRONT_DOMAIN}}/${ADMINFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEN "${BASE_DIR}/templates/adminfront/vite.config.ts" > "${TARGET_DIR}/adminfront/vite.config.ts" sed "s/{{DEVFRONT_DOMAIN}}/${DEVFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \ "${BASE_DIR}/templates/devfront/vite.config.ts" > "${TARGET_DIR}/devfront/vite.config.ts" +sed "s/{{ORGFRONT_DOMAIN}}/${ORGFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \ + "${BASE_DIR}/templates/orgfront/vite.config.ts" > "${TARGET_DIR}/orgfront/vite.config.ts" # 4. 프론트엔드 auth.ts 주입 (하드코딩된 포트 해결) sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g; s/{{CLIENT_ID}}/adminfront/g" \ "${BASE_DIR}/templates/auth.template.ts" > "${TARGET_DIR}/adminfront/auth.ts" sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g; s/{{CLIENT_ID}}/devfront/g" \ "${BASE_DIR}/templates/auth.template.ts" > "${TARGET_DIR}/devfront/auth.ts" +sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g" \ + "${BASE_DIR}/templates/orgfront/auth.ts" > "${TARGET_DIR}/orgfront/auth.ts" # 5. Ory 정적 설정 복사 +if [ -d "${BASE_DIR}/../docker/ory/init-db" ]; then cp -n "${BASE_DIR}/../docker/ory/init-db/"* "${TARGET_DIR}/ory/init-db/" 2>/dev/null || true; fi if [ -d "${BASE_DIR}/../docker/ory/kratos" ]; then cp -n "${BASE_DIR}/../docker/ory/kratos/"* "${TARGET_DIR}/ory/kratos/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/hydra" ]; then cp -n "${BASE_DIR}/../docker/ory/hydra/"* "${TARGET_DIR}/ory/hydra/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/keto" ]; then cp -n "${BASE_DIR}/../docker/ory/keto/"* "${TARGET_DIR}/ory/keto/" 2>/dev/null || true; fi if [ -d "${BASE_DIR}/../docker/ory/oathkeeper" ]; then cp -n "${BASE_DIR}/../docker/ory/oathkeeper/"* "${TARGET_DIR}/ory/oathkeeper/" 2>/dev/null || true; fi # 6. 마무리 diff --git a/deploy/templates/.env.template b/deploy/templates/.env.template index 130f5fc7..822f3095 100644 --- a/deploy/templates/.env.template +++ b/deploy/templates/.env.template @@ -17,6 +17,7 @@ BACKEND_PORT=${P}000 USERFRONT_PORT=${P}500 ADMINFRONT_PORT=${P}173 DEVFRONT_PORT=${P}174 +ORGFRONT_PORT=${P}175 OATHKEEPER_PROXY_PORT=${P}467 # === [3] 도메인 설정 (별도 도메인 구조) === @@ -25,23 +26,44 @@ DOMAIN_SUFFIX=hmac.kr USERFRONT_URL=https://{{INSTANCE_NAME}}-sso.${DOMAIN_SUFFIX} ADMINFRONT_URL=https://{{INSTANCE_NAME}}-admin.${DOMAIN_SUFFIX} DEVFRONT_URL=https://{{INSTANCE_NAME}}-dev.${DOMAIN_SUFFIX} +ORGFRONT_URL=https://{{INSTANCE_NAME}}-org.${DOMAIN_SUFFIX} # OIDC/Auth URL VITE_OIDC_AUTHORITY=${USERFRONT_URL}/oidc ADMINFRONT_CALLBACK_URLS=${ADMINFRONT_URL}/auth/callback DEVFRONT_CALLBACK_URLS=${DEVFRONT_URL}/auth/callback +ORGFRONT_CALLBACK_URLS=${ORGFRONT_URL}/auth/callback # Ory URL KRATOS_UI_URL=${USERFRONT_URL}/auth KRATOS_BROWSER_URL=${USERFRONT_URL}/auth +KRATOS_ADMIN_URL=http://kratos:4434 HYDRA_PUBLIC_URL=${USERFRONT_URL}/oidc +HYDRA_ADMIN_URL=http://hydra:4445 OATHKEEPER_PUBLIC_URL=${USERFRONT_URL} +KETO_READ_URL=http://keto:4466 +KETO_WRITE_URL=http://keto:4467 + +# Ory versions +KRATOS_VERSION=v26.2.0 +HYDRA_VERSION=v26.2.0 +KETO_VERSION=v26.2.0 +OATHKEEPER_VERSION=v26.2.0 +ORY_POSTGRES_TAG=17-alpine # === [4] IDP 및 DB Config === IDP_PROVIDER=ory DB_PASSWORD=password ORY_POSTGRES_USER=ory ORY_POSTGRES_PASSWORD=generated_secret_here +ORY_POSTGRES_DB=ory +KRATOS_DB=ory_kratos +HYDRA_DB=ory_hydra +KETO_DB=ory_keto +OATHKEEPER_UID=1001 +OATHKEEPER_GID=1001 +OATHKEEPER_INTROSPECT_CLIENT_ID=oathkeeper-introspect +OATHKEEPER_INTROSPECT_CLIENT_SECRET=oathkeeper-secret CLICKHOUSE_PASSWORD=password REDIS_ADDR=redis:6379 diff --git a/deploy/templates/docker-compose.yaml b/deploy/templates/docker-compose.yaml index c6294da9..5544039f 100644 --- a/deploy/templates/docker-compose.yaml +++ b/deploy/templates/docker-compose.yaml @@ -10,7 +10,7 @@ services: ports: - "${DB_PORT}:5432" volumes: - - db_data_${INSTANCE_NAME}:/var/lib/postgresql/data + - db_data:/var/lib/postgresql/data networks: [app_net] healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] @@ -33,42 +33,228 @@ services: - "${CLICKHOUSE_PORT_HTTP}:8123" - "${CLICKHOUSE_PORT_NATIVE}:9000" volumes: - - clickhouse_data_${INSTANCE_NAME}:/var/lib/clickhouse + - clickhouse_data:/var/lib/clickhouse networks: [app_net] # --- Ory Stack --- postgres_ory: - image: postgres:17-alpine + image: postgres:${ORY_POSTGRES_TAG:-17-alpine} container_name: ${COMPOSE_PROJECT_NAME}_ory_db environment: - - POSTGRES_USER=${ORY_POSTGRES_USER} + - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD} + - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} volumes: - - ory_db_data_${INSTANCE_NAME}:/var/lib/postgresql/data + - ory_db_data:/var/lib/postgresql/data + - ./ory/init-db:/docker-entrypoint-initdb.d:ro networks: [app_net] healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER}"] + test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}"] interval: 5s - kratos: - image: oryd/kratos:v25.4.0 - container_name: ${COMPOSE_PROJECT_NAME}_kratos + kratos-migrate: + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - ./ory/kratos:/etc/config/kratos:ro - command: serve -c /etc/config/kratos/kratos.yml --dev + command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes networks: [app_net] depends_on: postgres_ory: { condition: service_healthy } + kratos: + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_kratos + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - COOKIE_SECRET=${COOKIE_SECRET} + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login + volumes: + - ./ory/kratos:/etc/config/kratos:ro + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + networks: [app_net] + depends_on: + kratos-migrate: { condition: service_completed_successfully } + + hydra-migrate: + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + command: migrate sql up -e --yes + networks: [app_net] + depends_on: + postgres_ory: { condition: service_healthy } + + hydra: + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_hydra + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + - URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL} + - URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login} + - URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent} + - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} + - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} + volumes: + - ./ory/hydra:/etc/config/hydra:ro + command: serve -c /etc/config/hydra/hydra.yml all --dev + networks: [app_net] + depends_on: + hydra-migrate: { condition: service_completed_successfully } + + keto-migrate: + image: oryd/keto:${KETO_VERSION:-v26.2.0} + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./ory/keto:/etc/config/keto:ro + command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] + networks: [app_net] + depends_on: + postgres_ory: { condition: service_healthy } + + keto: + image: oryd/keto:${KETO_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_keto + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./ory/keto:/etc/config/keto:ro + command: serve -c /etc/config/keto/keto.yml + networks: [app_net] + depends_on: + keto-migrate: { condition: service_completed_successfully } + + oathkeeper_logs_init: + image: alpine:latest + command: ["sh", "-c", "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper"] + volumes: + - oathkeeper_logs:/var/log/oathkeeper + networks: [app_net] + oathkeeper: - image: oryd/oathkeeper:v25.4.0 + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0} container_name: ${COMPOSE_PROJECT_NAME}_oathkeeper env_file: .env + user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" ports: - "${OATHKEEPER_PROXY_PORT}:4455" + environment: + - APP_ENV=${APP_ENV:-production} + - LOG_LEVEL=debug + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - ./ory/oathkeeper:/etc/config/oathkeeper:ro + - oathkeeper_logs:/var/log/oathkeeper + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] + networks: [app_net] + depends_on: + oathkeeper_logs_init: { condition: service_completed_successfully } + kratos: { condition: service_started } + hydra: { condition: service_started } + + ory_stack_check: + image: alpine:latest + container_name: ${COMPOSE_PROJECT_NAME}_ory_stack_check + command: > + /bin/sh -c " + apk add --no-cache curl; + echo 'Wait for Ory services...'; + until curl -s http://kratos:4433/health/ready; do sleep 1; done; + until curl -s http://hydra:4444/health/ready; do sleep 1; done; + until curl -s http://keto:4466/health/ready; do sleep 1; done; + echo 'Ory stack is ready.';" + depends_on: + - kratos + - hydra + - keto + networks: [app_net] + + init-rp: + image: alpine:latest + env_file: .env + command: + - /bin/sh + - -ec + - | + apk add --no-cache curl tar + HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" + HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" + HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" + curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" + tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra + rm /tmp/hydra.tar.gz + + upsert_client() { + ID=$$1 + shift + if hydra get oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" "$$ID" >/dev/null 2>&1; then + hydra update oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" "$$ID" "$$@" + else + hydra create oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" --id "$$ID" "$$@" + fi + } + + upsert_client "adminfront" \ + --name "AdminFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-$${ADMINFRONT_URL}/auth/callback}" + + upsert_client "devfront" \ + --name "DevFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${DEVFRONT_CALLBACK_URLS:-$${DEVFRONT_URL}/auth/callback}" + + upsert_client "orgfront" \ + --name "OrgFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ORGFRONT_CALLBACK_URLS:-$${ORGFRONT_URL}/auth/callback}" + + upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ + --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ + --grant-type client_credentials \ + --response-type token \ + --scope openid,offline_access,profile,email + depends_on: + ory_stack_check: { condition: service_completed_successfully } networks: [app_net] # --- Application Services --- @@ -78,6 +264,14 @@ services: env_file: .env environment: - PORT=${BACKEND_PORT} + - APP_ENV=${APP_ENV:-production} + - IDP_PROVIDER=${IDP_PROVIDER:-ory} + - USERFRONT_URL=${USERFRONT_URL} + - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445} + - HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL} + - KETO_READ_URL=${KETO_READ_URL:-http://keto:4466} + - KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467} - DB_HOST=postgres - REDIS_ADDR=redis:6379 - CLICKHOUSE_HOST=clickhouse @@ -90,6 +284,7 @@ services: depends_on: postgres: { condition: service_healthy } redis: { condition: service_started } + oathkeeper: { condition: service_started } gateway: image: nginx:alpine @@ -147,6 +342,11 @@ networks: name: ${COMPOSE_PROJECT_NAME}_net volumes: - db_data_${INSTANCE_NAME}: - ory_db_data_${INSTANCE_NAME}: - clickhouse_data_${INSTANCE_NAME}: + db_data: + name: db_data_${INSTANCE_NAME} + ory_db_data: + name: ory_db_data_${INSTANCE_NAME} + clickhouse_data: + name: clickhouse_data_${INSTANCE_NAME} + oathkeeper_logs: + name: oathkeeper_logs_${INSTANCE_NAME} diff --git a/deploy/templates/gateway/nginx.conf b/deploy/templates/gateway/nginx.conf index 5590645e..656c5e2e 100644 --- a/deploy/templates/gateway/nginx.conf +++ b/deploy/templates/gateway/nginx.conf @@ -29,7 +29,6 @@ http { } location /oidc { - rewrite ^/oidc/(.*)$ /$1 break; proxy_pass http://oathkeeper_srv; proxy_set_header Host $host; } diff --git a/deploy/templates/ory/kratos/kratos.yml b/deploy/templates/ory/kratos/kratos.yml index b70ca3f2..45fa5952 100644 --- a/deploy/templates/ory/kratos/kratos.yml +++ b/deploy/templates/ory/kratos/kratos.yml @@ -1,16 +1,20 @@ -version: v25.4.0 +version: v26.2.0 dsn: ${DSN} serve: public: - base_url: http://localhost:4433/ + base_url: ${KRATOS_BROWSER_URL} cors: enabled: true allowed_origins: - http://backend:{{BACKEND_PORT}} + - ${USERFRONT_URL} + - ${ADMINFRONT_URL} + - ${DEVFRONT_URL} + - ${ORGFRONT_URL} admin: - base_url: http://localhost:4434/ + base_url: ${KRATOS_ADMIN_URL} session: cookie: @@ -19,30 +23,22 @@ session: path: / selfservice: - default_browser_return_url: http://localhost:{{USERFRONT_PORT}}/ + default_browser_return_url: ${KRATOS_UI_URL} allowed_return_urls: - - http://backend:{{BACKEND_PORT}} - - http://backend:{{BACKEND_PORT}}/ - - http://localhost:{{USERFRONT_PORT}} - - https://app.brsw.kr - - https://app.brsw.kr/ - - https://sss.hmac.kr - - https://sss.hmac.kr/ - - https://sso.hmac.kr - - https://sso.hmac.kr/ - - https://ssologin.hmac.kr - - https://ssologin.hmac.kr/ - - https://sso-test.hmac.kr - - https://sso-test.hmac.kr/ - - https://ssob.hmac.kr - - https://ssob.hmac.kr/ - - https://ssob.hmac.kr/ko - - https://ssob.hmac.kr/ko/ - - https://ssob.hmac.kr/en - - https://ssob.hmac.kr/en/ - - https://ssob.hmac.kr/auth/callback - - https://ssob.hmac.kr/ko/auth/callback - - https://ssob.hmac.kr/en/auth/callback + - ${KRATOS_UI_URL} + - ${KRATOS_UI_URL}/ + - ${USERFRONT_URL} + - ${USERFRONT_URL}/ + - ${USERFRONT_URL}/ko + - ${USERFRONT_URL}/ko/ + - ${USERFRONT_URL}/en + - ${USERFRONT_URL}/en/ + - ${USERFRONT_URL}/auth/callback + - ${USERFRONT_URL}/ko/auth/callback + - ${USERFRONT_URL}/en/auth/callback + - ${ADMINFRONT_URL}/auth/callback + - ${DEVFRONT_URL}/auth/callback + - ${ORGFRONT_URL}/auth/callback methods: password: @@ -55,24 +51,24 @@ selfservice: flows: error: - ui_url: http://localhost:{{USERFRONT_PORT}}/error + ui_url: ${KRATOS_UI_URL}/error settings: - ui_url: http://localhost:{{USERFRONT_PORT}}/error?error=settings_disabled + ui_url: ${KRATOS_UI_URL}/error?error=settings_disabled privileged_session_max_age: 15m recovery: - ui_url: http://localhost:{{USERFRONT_PORT}}/recovery + ui_url: ${KRATOS_UI_URL}/recovery use: code verification: - ui_url: http://localhost:{{USERFRONT_PORT}}/verification + ui_url: ${KRATOS_UI_URL}/verification use: code logout: after: - default_browser_return_url: http://localhost:{{USERFRONT_PORT}}/login + default_browser_return_url: ${KRATOS_UI_URL}/login login: - ui_url: http://localhost:{{USERFRONT_PORT}}/login + ui_url: ${KRATOS_UI_URL}/login lifespan: 10m registration: - ui_url: http://localhost:{{USERFRONT_PORT}}/registration + ui_url: ${KRATOS_UI_URL}/registration lifespan: 10m log: diff --git a/deploy/templates/ory/oathkeeper/rules.json b/deploy/templates/ory/oathkeeper/rules.json index 00fe02e3..ea15e287 100644 --- a/deploy/templates/ory/oathkeeper/rules.json +++ b/deploy/templates/ory/oathkeeper/rules.json @@ -1,9 +1,52 @@ [ { - "id": "backend-api-rule", + "id": "public-health", + "description": "공개 헬스체크", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", - "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"] + "url": "<.*>://<[^/]+>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://backend:{{BACKEND_PORT}}" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "public-preflight", + "description": "CORS preflight", + "match": { + "url": "<.*>://<[^/]+>/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://backend:{{BACKEND_PORT}}" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "<.*>://<[^/]+>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://backend:{{BACKEND_PORT}}" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "<.*>://<[^/]+>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { "url": "http://backend:{{BACKEND_PORT}}" @@ -11,5 +54,106 @@ "authenticators": [{ "handler": "cookie_session" }], "authorizer": { "handler": "remote_json" }, "mutators": [{ "handler": "noop" }] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "<.*>://<[^/]+>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://backend:{{BACKEND_PORT}}" + }, + "authenticators": [{ "handler": "cookie_session" }], + "authorizer": { "handler": "remote_json" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-well-known", + "description": "Hydra OIDC Discovery & JWKS", + "match": { + "url": "<.*>://<[^/]+>/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-well-known-oidc", + "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", + "match": { + "url": "<.*>://<[^/]+>/oidc/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-oauth2", + "description": "Hydra OAuth2 Endpoints", + "match": { + "url": "<.*>://<[^/]+>/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-oauth2-oidc", + "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", + "match": { + "url": "<.*>://<[^/]+>/oidc/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo", + "description": "Hydra Userinfo", + "match": { + "url": "<.*>://<[^/]+>/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo-oidc", + "description": "Hydra Userinfo (with /oidc prefix)", + "match": { + "url": "<.*>://<[^/]+>/oidc/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] } ] diff --git a/docker/compose.ory.yaml b/docker/compose.ory.yaml index a360fb9c..02a455e9 100644 --- a/docker/compose.ory.yaml +++ b/docker/compose.ory.yaml @@ -22,13 +22,13 @@ services: retries: 5 kratos-migrate: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}" - - KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}" - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}" - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS='["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]' + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${USERFRONT_URL}"]} volumes: - ./docker/ory/kratos:/etc/config/kratos command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes @@ -39,15 +39,15 @@ services: - ory-net kratos: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} container_name: ory_kratos environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - COOKIE_SECRET="${COOKIE_SECRET:-localcookie123}" - - KRATOS_SERVE_PUBLIC_BASE_URL="${KRATOS_BROWSER_URL:-http://localhost:4433}" - - KRATOS_SERVE_ADMIN_BASE_URL="${KRATOS_ADMIN_URL:-http://kratos:4434}" - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL="${KRATOS_UI_URL:-http://localhost:5000}" - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS='["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"]' + - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${USERFRONT_URL}"]} volumes: - ./docker/ory/kratos:/etc/config/kratos command: serve -c /etc/config/kratos/kratos.yml @@ -59,7 +59,7 @@ services: - kratosnet hydra-migrate: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 command: migrate sql up -e --yes @@ -70,13 +70,14 @@ services: - ory-net hydra: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} container_name: ory_hydra environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc - - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login - - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent + - URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL} + - URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login} + - URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent} + - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} volumes: - ./docker/ory/hydra:/etc/config/hydra @@ -89,7 +90,7 @@ services: - hydranet keto-migrate: - image: oryd/keto:${KETO_VERSION:-v25.4.0} + image: oryd/keto:${KETO_VERSION:-v26.2.0} environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: @@ -102,7 +103,7 @@ services: - ory-net keto: - image: oryd/keto:${KETO_VERSION:-v25.4.0} + image: oryd/keto:${KETO_VERSION:-v26.2.0} container_name: ory_keto environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 @@ -116,21 +117,24 @@ services: - ory-net oathkeeper: - image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6} + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0} container_name: oathkeeper restart: unless-stopped depends_on: kratos: condition: service_started environment: + - APP_ENV=${APP_ENV:-development} - LOG_LEVEL=debug - command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper - oathkeeper_logs:/var/log/oathkeeper + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: - ory-net - - baron_net + - baron_net - public_net ports: - "4455:4455" @@ -168,47 +172,47 @@ services: - /bin/sh - -ec - | - apk add --no-cache curl tar - HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" - HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" - HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" - curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" - tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra - rm /tmp/hydra.tar.gz + apk add --no-cache curl tar + HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" + HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" + HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" + curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" + tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra + rm /tmp/hydra.tar.gz - echo "Creating/Updating OAuth2 Clients..." - - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id adminfront \ - --name "AdminFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback,http://172.16.10.176:5173/auth/callback} + echo "Creating/Updating OAuth2 Clients..." - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id devfront \ - --name "DevFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback,http://172.16.10.176:5174/auth/callback} + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id adminfront \ + --name "AdminFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ADMINFRONT_CALLBACK_URLS}" - hydra create oauth2-client \ - --endpoint http://hydra:4445 \ - --id orgfront \ - --name "OrgFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri ${ORGFRONT_CALLBACK_URLS:-http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback} + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id devfront \ + --name "DevFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${DEVFRONT_CALLBACK_URLS}" - echo "All RP clients initialized successfully." + hydra create oauth2-client \ + --endpoint "$${HYDRA_ADMIN_URL}" \ + --id orgfront \ + --name "OrgFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ORGFRONT_CALLBACK_URLS}" + + echo "All RP clients initialized successfully." depends_on: ory_stack_check: condition: service_completed_successfully diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 9ccd01f2..d1bd22cd 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -4,23 +4,18 @@ dsn: ${DSN} serve: public: - base_url: http://localhost:4433/ + base_url: ${KRATOS_BROWSER_URL} cors: enabled: true allowed_origins: - - http://localhost:5173 - - http://localhost:5174 - - http://localhost:5175 - - http://localhost:5000 + - ${USERFRONT_URL} + - ${ADMINFRONT_URL} + - ${DEVFRONT_URL} + - ${ORGFRONT_URL} - http://backend:3000 - http://baron_backend:3000 - - https://ssologin.hmac.kr - - https://sso-test.hmac.kr - - https://app.brsw.kr - - https://sss.hmac.kr - - https://sso.hmac.kr admin: - base_url: http://localhost:4434/ + base_url: ${KRATOS_ADMIN_URL} session: cookie: @@ -29,21 +24,22 @@ session: path: / selfservice: - default_browser_return_url: http://localhost:5000/ + default_browser_return_url: ${KRATOS_UI_URL} allowed_return_urls: - - http://baron_backend:3000 - - http://baron_backend:3000/ - - http://localhost:5000 - - https://app.brsw.kr - - https://app.brsw.kr/ - - https://sss.hmac.kr - - https://sss.hmac.kr/ - - https://sso.hmac.kr - - https://sso.hmac.kr/ - - https://ssologin.hmac.kr - - https://ssologin.hmac.kr/ - - https://sso-test.hmac.kr - - https://sso-test.hmac.kr/ + - ${KRATOS_UI_URL} + - ${KRATOS_UI_URL}/ + - ${USERFRONT_URL} + - ${USERFRONT_URL}/ + - ${USERFRONT_URL}/ko + - ${USERFRONT_URL}/ko/ + - ${USERFRONT_URL}/en + - ${USERFRONT_URL}/en/ + - ${USERFRONT_URL}/auth/callback + - ${USERFRONT_URL}/ko/auth/callback + - ${USERFRONT_URL}/en/auth/callback + - ${ADMINFRONT_URL}/auth/callback + - ${DEVFRONT_URL}/auth/callback + - ${ORGFRONT_URL}/auth/callback methods: password: @@ -56,24 +52,24 @@ selfservice: flows: error: - ui_url: http://localhost:5000/error + ui_url: ${KRATOS_UI_URL}/error settings: - ui_url: http://localhost:5000/error?error=settings_disabled + ui_url: ${KRATOS_UI_URL}/error?error=settings_disabled privileged_session_max_age: 15m recovery: - ui_url: http://localhost:5000/recovery + ui_url: ${KRATOS_UI_URL}/recovery use: code verification: - ui_url: http://localhost:5000/verification + ui_url: ${KRATOS_UI_URL}/verification use: code logout: after: - default_browser_return_url: http://localhost:5000/login + default_browser_return_url: ${KRATOS_UI_URL}/login login: - ui_url: http://localhost:5000/login + ui_url: ${KRATOS_UI_URL}/login lifespan: 10m registration: - ui_url: http://localhost:5000/registration + ui_url: ${KRATOS_UI_URL}/registration lifespan: 10m log: diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index 4a0735da..e1331f74 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -3,7 +3,7 @@ "id": "public-health", "description": "공개 헬스체크", "match": { - "url": "<.*>://<.*>/health", + "url": "<.*>://<[^/]+>/health", "methods": ["GET"] }, "upstream": { @@ -17,7 +17,7 @@ "id": "public-preflight", "description": "CORS preflight", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -31,7 +31,7 @@ "id": "public-auth", "description": "인증/회원가입 등 공개 엔드포인트", "match": { - "url": "<.*>://<.*>/api/v1/auth/<.*>", + "url": "<.*>://<[^/]+>/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -45,7 +45,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -59,7 +59,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["GET"] }, "upstream": { @@ -73,7 +73,7 @@ "id": "hydra-well-known", "description": "Hydra OIDC Discovery & JWKS", "match": { - "url": "<.*>://<.*>/.well-known/<.*>", + "url": "<.*>://<[^/]+>/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { @@ -87,12 +87,12 @@ "id": "hydra-well-known-oidc", "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/.well-known/<.*>", + "url": "<.*>://<[^/]+>/oidc/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, @@ -102,7 +102,7 @@ "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", "match": { - "url": "<.*>://<.*>/oauth2/<.*>", + "url": "<.*>://<[^/]+>/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { @@ -116,12 +116,12 @@ "id": "hydra-oauth2-oidc", "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/oauth2/<.*>", + "url": "<.*>://<[^/]+>/oidc/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, @@ -131,7 +131,7 @@ "id": "hydra-userinfo", "description": "Hydra Userinfo", "match": { - "url": "<.*>://<.*>/userinfo", + "url": "<.*>://<[^/]+>/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -145,12 +145,12 @@ "id": "hydra-userinfo-oidc", "description": "Hydra Userinfo (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/userinfo", + "url": "<.*>://<[^/]+>/oidc/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json index fd6bfb2d..da75325f 100755 --- a/docker/ory/oathkeeper/rules.json +++ b/docker/ory/oathkeeper/rules.json @@ -3,7 +3,7 @@ "id": "public-health", "description": "공개 헬스체크", "match": { - "url": "<.*>://<.*>/health", + "url": "<.*>://<[^/]+>/health", "methods": ["GET"] }, "upstream": { @@ -17,7 +17,7 @@ "id": "public-preflight", "description": "CORS preflight", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -31,7 +31,7 @@ "id": "public-auth", "description": "인증/회원가입 등 공개 엔드포인트", "match": { - "url": "<.*>://<.*>/api/v1/auth/<.*>", + "url": "<.*>://<[^/]+>/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -45,7 +45,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -59,7 +59,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["GET"] }, "upstream": { @@ -73,7 +73,7 @@ "id": "hydra-well-known", "description": "Hydra OIDC Discovery & JWKS", "match": { - "url": "<.*>://<.*>/.well-known/<.*>", + "url": "<.*>://<[^/]+>/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { @@ -87,12 +87,12 @@ "id": "hydra-well-known-oidc", "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/.well-known/<.*>", + "url": "<.*>://<[^/]+>/oidc/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, @@ -102,7 +102,7 @@ "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", "match": { - "url": "<.*>://<.*>/oauth2/<.*>", + "url": "<.*>://<[^/]+>/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { @@ -116,12 +116,12 @@ "id": "hydra-oauth2-oidc", "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/oauth2/<.*>", + "url": "<.*>://<[^/]+>/oidc/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, @@ -131,7 +131,7 @@ "id": "hydra-userinfo", "description": "Hydra Userinfo", "match": { - "url": "<.*>://<.*>/userinfo", + "url": "<.*>://<[^/]+>/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -145,12 +145,12 @@ "id": "hydra-userinfo-oidc", "description": "Hydra Userinfo (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/userinfo", + "url": "<.*>://<[^/]+>/oidc/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json index ef5524d5..6728bb7a 100755 --- a/docker/ory/oathkeeper/rules.prod.json +++ b/docker/ory/oathkeeper/rules.prod.json @@ -1,9 +1,9 @@ [ { "id": "public-health", - "description": "공개 헬스체크 (PROD 도메인)", + "description": "공개 헬스체크 (PROD)", "match": { - "url": "https://app.brsw.kr/health", + "url": "<.*>://<[^/]+>/health", "methods": ["GET"] }, "upstream": { @@ -15,9 +15,9 @@ }, { "id": "public-preflight", - "description": "CORS preflight (PROD 도메인)", + "description": "CORS preflight (PROD)", "match": { - "url": "https://app.brsw.kr/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -29,9 +29,9 @@ }, { "id": "public-auth", - "description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)", + "description": "인증/회원가입 등 공개 엔드포인트 (PROD)", "match": { - "url": "https://app.brsw.kr/api/v1/auth/<.*>", + "url": "<.*>://<[^/]+>/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -45,7 +45,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "https://app.brsw.kr/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -59,7 +59,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "https://app.brsw.kr/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["GET"] }, "upstream": { @@ -68,5 +68,92 @@ "authenticators": [{ "handler": "cookie_session" }], "authorizer": { "handler": "remote_json" }, "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-well-known", + "description": "Hydra OIDC Discovery & JWKS (PROD)", + "match": { + "url": "<.*>://<[^/]+>/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-well-known-oidc", + "description": "Hydra OIDC Discovery & JWKS with /oidc prefix (PROD)", + "match": { + "url": "<.*>://<[^/]+>/oidc/.well-known/<.*>", + "methods": ["GET", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-oauth2", + "description": "Hydra OAuth2 Endpoints (PROD)", + "match": { + "url": "<.*>://<[^/]+>/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-oauth2-oidc", + "description": "Hydra OAuth2 Endpoints with /oidc prefix (PROD 도메인)", + "match": { + "url": "<.*>://<[^/]+>/oidc/oauth2/<.*>", + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo", + "description": "Hydra Userinfo (PROD)", + "match": { + "url": "<.*>://<[^/]+>/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] + }, + { + "id": "hydra-userinfo-oidc", + "description": "Hydra Userinfo with /oidc prefix (PROD 도메인)", + "match": { + "url": "<.*>://<[^/]+>/oidc/userinfo", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/oidc" + }, + "authenticators": [{ "handler": "noop" }], + "authorizer": { "handler": "allow" }, + "mutators": [{ "handler": "noop" }] } ] diff --git a/docker/ory/oathkeeper/rules.stage.json b/docker/ory/oathkeeper/rules.stage.json index 4a0735da..e1331f74 100755 --- a/docker/ory/oathkeeper/rules.stage.json +++ b/docker/ory/oathkeeper/rules.stage.json @@ -3,7 +3,7 @@ "id": "public-health", "description": "공개 헬스체크", "match": { - "url": "<.*>://<.*>/health", + "url": "<.*>://<[^/]+>/health", "methods": ["GET"] }, "upstream": { @@ -17,7 +17,7 @@ "id": "public-preflight", "description": "CORS preflight", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -31,7 +31,7 @@ "id": "public-auth", "description": "인증/회원가입 등 공개 엔드포인트", "match": { - "url": "<.*>://<.*>/api/v1/auth/<.*>", + "url": "<.*>://<[^/]+>/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -45,7 +45,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -59,7 +59,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "<.*>://<.*>/api/v1/<.*>", + "url": "<.*>://<[^/]+>/api/v1/<.*>", "methods": ["GET"] }, "upstream": { @@ -73,7 +73,7 @@ "id": "hydra-well-known", "description": "Hydra OIDC Discovery & JWKS", "match": { - "url": "<.*>://<.*>/.well-known/<.*>", + "url": "<.*>://<[^/]+>/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { @@ -87,12 +87,12 @@ "id": "hydra-well-known-oidc", "description": "Hydra OIDC Discovery & JWKS (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/.well-known/<.*>", + "url": "<.*>://<[^/]+>/oidc/.well-known/<.*>", "methods": ["GET", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, @@ -102,7 +102,7 @@ "id": "hydra-oauth2", "description": "Hydra OAuth2 Endpoints", "match": { - "url": "<.*>://<.*>/oauth2/<.*>", + "url": "<.*>://<[^/]+>/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { @@ -116,12 +116,12 @@ "id": "hydra-oauth2-oidc", "description": "Hydra OAuth2 Endpoints (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/oauth2/<.*>", + "url": "<.*>://<[^/]+>/oidc/oauth2/<.*>", "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, @@ -131,7 +131,7 @@ "id": "hydra-userinfo", "description": "Hydra Userinfo", "match": { - "url": "<.*>://<.*>/userinfo", + "url": "<.*>://<[^/]+>/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -145,12 +145,12 @@ "id": "hydra-userinfo-oidc", "description": "Hydra Userinfo (with /oidc prefix)", "match": { - "url": "<.*>://<.*>/oidc/userinfo", + "url": "<.*>://<[^/]+>/oidc/userinfo", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { "url": "http://hydra:4444", - "strip_path_prefix": "/oidc" + "strip_path": "/oidc" }, "authenticators": [{ "handler": "noop" }], "authorizer": { "handler": "allow" }, diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 15195a52..d4a91f9c 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -1,501 +1,521 @@ services: - postgres: - image: postgres:17-alpine - container_name: baron_postgres - environment: - POSTGRES_USER: "${DB_USER:-baron}" - POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" - POSTGRES_DB: "${DB_NAME:-baron_sso}" - ports: - - "${DB_PORT:-5432}:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./docker/init-metadata:/docker-entrypoint-initdb.d - networks: - - baron_net - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}", - ] - interval: 5s - timeout: 5s - retries: 5 - restart: always + postgres: + image: postgres:17-alpine + container_name: baron_postgres + environment: + POSTGRES_USER: "${DB_USER:-baron}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_NAME:-baron_sso}" + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/init-metadata:/docker-entrypoint-initdb.d + networks: + - baron_net + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}", + ] + interval: 5s + timeout: 5s + retries: 5 + restart: always - clickhouse: - image: clickhouse/clickhouse-server:latest - container_name: baron_clickhouse - restart: always - volumes: - - clickhouse_data:/var/lib/clickhouse - environment: - CLICKHOUSE_USER: "${CLICKHOUSE_USER:-baron}" - CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:-password}" - networks: - - baron_net - healthcheck: - test: ["CMD", "clickhouse-client", "--query", "SELECT 1"] - interval: 5s - timeout: 5s - retries: 5 + clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: baron_clickhouse + restart: always + volumes: + - clickhouse_data:/var/lib/clickhouse + environment: + CLICKHOUSE_USER: "${CLICKHOUSE_USER:-baron}" + CLICKHOUSE_PASSWORD: "${CLICKHOUSE_PASSWORD:-password}" + networks: + - baron_net + healthcheck: + test: ["CMD", "clickhouse-client", "--query", "SELECT 1"] + interval: 5s + timeout: 5s + retries: 5 - redis: - image: redis:7-alpine - container_name: baron_redis - restart: always - command: redis-server --port 6389 - ports: - - "6389:6389" - volumes: - - redis_data:/data - networks: - - baron_net - healthcheck: - test: ["CMD", "redis-cli", "-p", "6389", "ping"] - interval: 5s - timeout: 5s - retries: 5 + redis: + image: redis:7-alpine + container_name: baron_redis + restart: always + command: redis-server --port 6389 + ports: + - "6389:6389" + volumes: + - redis_data:/data + networks: + - baron_net + healthcheck: + test: ["CMD", "redis-cli", "-p", "6389", "ping"] + interval: 5s + timeout: 5s + retries: 5 - gateway: - image: nginx:alpine - container_name: baron_gateway - restart: always - ports: - - "${USERFRONT_PORT:-5000}:5000" - volumes: - - ./gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro - networks: - - baron_net - - public_net - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s + gateway: + image: nginx:alpine + container_name: baron_gateway + restart: always + ports: + - "${USERFRONT_PORT:-5000}:5000" + volumes: + - ./gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - baron_net + - public_net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s - postgres_ory: - image: postgres:${ORY_POSTGRES_TAG:-17-alpine} - container_name: ory_postgres - environment: - - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} - - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret} - - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} - volumes: - - ./docker/ory/init-db:/docker-entrypoint-initdb.d - - ory_postgres_data:/var/lib/postgresql/data - networks: - - ory-net - healthcheck: - test: - [ - "CMD-SHELL", - "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}", - ] - interval: 5s - timeout: 5s - retries: 5 + postgres_ory: + image: postgres:${ORY_POSTGRES_TAG:-17-alpine} + container_name: ory_postgres + environment: + - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} + - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret} + - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} + volumes: + - ./docker/ory/init-db:/docker-entrypoint-initdb.d + - ory_postgres_data:/var/lib/postgresql/data + networks: + - ory-net + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}", + ] + interval: 5s + timeout: 5s + retries: 5 - kratos-migrate: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - volumes: - - ./docker/ory/kratos:/etc/config/kratos - command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes - depends_on: - postgres_ory: - condition: service_healthy - networks: - - ory-net + kratos-migrate: + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login + volumes: + - ./docker/ory/kratos:/etc/config/kratos + command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net - kratos: - image: oryd/kratos:${KRATOS_VERSION:-v25.4.0} - container_name: ory_kratos - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 - - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} - - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL:-http://localhost:4433} - - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000} - - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=["${KRATOS_UI_URL:-http://localhost:5000}","${USERFRONT_URL:-http://localhost:5000}"] - - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error - - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/error?error=settings_disabled - - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/recovery - - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/verification - - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL:-http://localhost:5000}/registration - - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL:-http://localhost:5000}/login - volumes: - - ./docker/ory/kratos:/etc/config/kratos - command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier - depends_on: - kratos-migrate: - condition: service_completed_successfully - networks: - - ory-net - - kratosnet + kratos: + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} + container_name: ory_kratos + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - COOKIE_SECRET=${COOKIE_SECRET:-localcookie123} + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login + volumes: + - ./docker/ory/kratos:/etc/config/kratos + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + depends_on: + kratos-migrate: + condition: service_completed_successfully + networks: + - ory-net + - kratosnet - hydra-migrate: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - command: migrate sql up -e --yes - depends_on: - postgres_ory: - condition: service_healthy - networks: - - ory-net + hydra-migrate: + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + command: migrate sql up -e --yes + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net - hydra: - image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} - container_name: ory_hydra - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 - - URLS_SELF_ISSUER=${USERFRONT_URL:-http://localhost:5000}/oidc - - URLS_LOGIN=${USERFRONT_URL:-http://localhost:5000}/login - - URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent - - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} - volumes: - - ./docker/ory/hydra:/etc/config/hydra - command: serve -c /etc/config/hydra/hydra.yml all --dev - depends_on: - hydra-migrate: - condition: service_completed_successfully - networks: - - ory-net - - hydranet + hydra: + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} + container_name: ory_hydra + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + - URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL} + - URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login} + - URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent} + - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} + - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} + volumes: + - ./docker/ory/hydra:/etc/config/hydra + command: serve -c /etc/config/hydra/hydra.yml all --dev + depends_on: + hydra-migrate: + condition: service_completed_successfully + networks: + - ory-net + - hydranet - keto-migrate: - image: oryd/keto:${KETO_VERSION:-v25.4.0} - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 - volumes: - - ./docker/ory/keto:/etc/config/keto - command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] - depends_on: - postgres_ory: - condition: service_healthy - networks: - - ory-net + keto-migrate: + image: oryd/keto:${KETO_VERSION:-v26.2.0} + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] + depends_on: + postgres_ory: + condition: service_healthy + networks: + - ory-net - keto: - image: oryd/keto:${KETO_VERSION:-v25.4.0} - container_name: ory_keto - environment: - - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 - volumes: - - ./docker/ory/keto:/etc/config/keto - command: serve -c /etc/config/keto/keto.yml - depends_on: - keto-migrate: - condition: service_completed_successfully - networks: - - ory-net + keto: + image: oryd/keto:${KETO_VERSION:-v26.2.0} + container_name: ory_keto + environment: + - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./docker/ory/keto:/etc/config/keto + command: serve -c /etc/config/keto/keto.yml + depends_on: + keto-migrate: + condition: service_completed_successfully + networks: + - ory-net - oathkeeper: - image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6} - container_name: oathkeeper - restart: unless-stopped - depends_on: - kratos: - condition: service_started - environment: - - LOG_LEVEL=debug - command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml - volumes: - - ./docker/ory/oathkeeper:/etc/config/oathkeeper - - oathkeeper_logs:/var/log/oathkeeper - networks: - - ory-net - - baron_net - - public_net - ports: - - "4455:4455" - - "4456:4456" - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"] - interval: 5s - timeout: 5s - retries: 5 + oathkeeper_logs_init: + image: alpine:latest + command: + [ + "sh", + "-c", + "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper", + ] + volumes: + - oathkeeper_logs:/var/log/oathkeeper + networks: + - ory-net - ory_clickhouse: - image: clickhouse/clickhouse-server:latest - container_name: ory_clickhouse - environment: - - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory} - - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass} - volumes: - - ory_clickhouse_data:/var/lib/clickhouse - - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d - networks: - - ory-net + oathkeeper: + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v0.40.6} + container_name: oathkeeper + restart: unless-stopped + depends_on: + oathkeeper_logs_init: + condition: service_completed_successfully + kratos: + condition: service_started + user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" + environment: + - APP_ENV=${APP_ENV:-stage} + - LOG_LEVEL=debug + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} + volumes: + - ./docker/ory/oathkeeper:/etc/config/oathkeeper + - oathkeeper_logs:/var/log/oathkeeper + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] + networks: + - ory-net + - baron_net + - public_net + ports: + - "4455:4455" + - "4456:4456" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4456/health/ready"] + interval: 5s + timeout: 5s + retries: 5 - ory_stack_check: - image: alpine:latest - container_name: ory_stack_check - command: > - /bin/sh -c " - apk add --no-cache curl; - echo 'Wait for services...'; - until curl -s http://kratos:4433/health/ready; do sleep 1; done; - until curl -s http://hydra:4444/health/ready; do sleep 1; done; - until curl -s http://keto:4466/health/ready; do sleep 1; done; - echo 'Ory Stack is fully operational!';" - depends_on: - - kratos - - hydra - - keto - networks: - - ory-net + ory_clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: ory_clickhouse + environment: + - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory} + - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass} + volumes: + - ory_clickhouse_data:/var/lib/clickhouse + - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d + networks: + - ory-net - init-rp: - image: alpine:latest - env_file: - - .env - command: - - /bin/sh - - -ec - - | - apk add --no-cache curl tar - HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" - HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" - HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" - curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" - tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra - rm /tmp/hydra.tar.gz + ory_stack_check: + image: alpine:latest + container_name: ory_stack_check + command: > + /bin/sh -c " + apk add --no-cache curl; + echo 'Wait for services...'; + until curl -s http://kratos:4433/health/ready; do sleep 1; done; + until curl -s http://hydra:4444/health/ready; do sleep 1; done; + until curl -s http://keto:4466/health/ready; do sleep 1; done; + echo 'Ory Stack is fully operational!';" + depends_on: + - kratos + - hydra + - keto + networks: + - ory-net - # Function to create or update OAuth2 client (Idempotency) - upsert_client() { - ID=$$1 - shift - if hydra get oauth2-client --endpoint http://hydra:4445 "$$ID" >/dev/null 2>&1; then - echo "Updating existing client: $$ID" - hydra update oauth2-client --endpoint http://hydra:4445 "$$ID" "$$@" - else - echo "Creating new client: $$ID" - hydra create oauth2-client --endpoint http://hydra:4445 --id "$$ID" "$$@" - fi - } + init-rp: + image: alpine:latest + env_file: + - .env + command: + - /bin/sh + - -ec + - | + apk add --no-cache curl tar + HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}" + HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}" + HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}" + curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz" + tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra + rm /tmp/hydra.tar.gz - upsert_client "adminfront" \ - --name "AdminFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" + # Function to create or update OAuth2 client (Idempotency) + upsert_client() { + ID=$$1 + shift + if hydra get oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" "$$ID" >/dev/null 2>&1; then + echo "Updating existing client: $$ID" + hydra update oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" "$$ID" "$$@" + else + echo "Creating new client: $$ID" + hydra create oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" --id "$$ID" "$$@" + fi + } - upsert_client "devfront" \ - --name "DevFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri "$${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/auth/callback}" + upsert_client "adminfront" \ + --name "AdminFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-$${ADMINFRONT_URL}/auth/callback}" - upsert_client "orgfront" \ - --name "OrgFront" \ - --grant-type authorization_code,refresh_token \ - --response-type code \ - --scope openid,offline_access,profile,email \ - --token-endpoint-auth-method none \ - --redirect-uri "$${ORGFRONT_CALLBACK_URLS:-http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback, https://baron-orgchart.hmac.kr/auth/callback}" + upsert_client "devfront" \ + --name "DevFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${DEVFRONT_CALLBACK_URLS:-$${DEVFRONT_URL}/auth/callback}" - upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ - --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ - --grant-type client_credentials \ - --response-type token \ - --scope openid,offline_access,profile,email - depends_on: - ory_stack_check: - condition: service_completed_successfully - networks: - - hydranet + upsert_client "orgfront" \ + --name "OrgFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ORGFRONT_CALLBACK_URLS:-$${ORGFRONT_URL}/auth/callback}" - backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: baron_backend - env_file: - - .env - environment: - - APP_ENV=${APP_ENV:-development} - - GO_ENV=${APP_ENV:-development} - - COOKIE_SECRET=${COOKIE_SECRET} - - JWT_SECRET=${JWT_SECRET} - - NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY} - - NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY} - - NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID} - - NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER} - - USERFRONT_URL=${USERFRONT_URL} - - REDIS_ADDR=${REDIS_ADDR} - - IDP_PROVIDER=${IDP_PROVIDER:-ory} - - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - - HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445} - - HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444} - - DB_HOST=postgres - - CLICKHOUSE_HOST=clickhouse - - CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000} - - CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron} - - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password} - - SEED_TENANT_CSV_PATH=/app/seed-tenant.csv - depends_on: - clickhouse: - condition: service_healthy - redis: - condition: service_healthy - oathkeeper: - condition: service_healthy - kratos: - condition: service_started - hydra: - condition: service_started - keto: - condition: service_started - infra_check: - condition: service_started - networks: - - baron_net - - ory-net - volumes: - - ./backend:/app - - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - command: ["go", "run", "./cmd/server"] - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s + upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ + --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ + --grant-type client_credentials \ + --response-type token \ + --scope openid,offline_access,profile,email + depends_on: + ory_stack_check: + condition: service_completed_successfully + networks: + - hydranet - adminfront: - build: - context: ./adminfront - dockerfile: Dockerfile - container_name: baron_adminfront - env_file: - - .env - environment: - - APP_ENV=${APP_ENV:-development} - - API_PROXY_TARGET=http://baron_backend:3000 - ports: - - "${ADMINFRONT_PORT:-5173}:5173" - volumes: - - ./adminfront:/app - - /app/node_modules - networks: - - baron_net + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: baron_backend + env_file: + - .env + environment: + - APP_ENV=${APP_ENV:-development} + - GO_ENV=${APP_ENV:-development} + - COOKIE_SECRET=${COOKIE_SECRET} + - JWT_SECRET=${JWT_SECRET} + - NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY} + - NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY} + - NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID} + - NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER} + - USERFRONT_URL=${USERFRONT_URL} + - REDIS_ADDR=${REDIS_ADDR} + - IDP_PROVIDER=${IDP_PROVIDER:-ory} + - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445} + - HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL} + - DB_HOST=postgres + - CLICKHOUSE_HOST=clickhouse + - CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000} + - CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron} + - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password} + - SEED_TENANT_CSV_PATH=/app/seed-tenant.csv + depends_on: + clickhouse: + condition: service_healthy + redis: + condition: service_healthy + oathkeeper: + condition: service_healthy + kratos: + condition: service_started + hydra: + condition: service_started + keto: + condition: service_started + infra_check: + condition: service_started + networks: + - baron_net + - ory-net + volumes: + - ./backend:/app + - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro + command: ["go", "run", "./cmd/server"] + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s - devfront: - build: - context: ./devfront - dockerfile: Dockerfile - container_name: baron_devfront - env_file: - - .env - environment: - - APP_ENV=${APP_ENV:-development} - - API_PROXY_TARGET=http://baron_backend:3000 - ports: - - "${DEVFRONT_PORT:-5174}:5173" - volumes: - - ./devfront:/app - - /app/node_modules - networks: - - baron_net + adminfront: + build: + context: ./adminfront + dockerfile: Dockerfile + container_name: baron_adminfront + env_file: + - .env + environment: + - APP_ENV=${APP_ENV:-development} + - API_PROXY_TARGET=http://baron_backend:3000 + ports: + - "${ADMINFRONT_PORT:-5173}:5173" + volumes: + - ./adminfront:/app + - /app/node_modules + networks: + - baron_net - orgfront: - build: - context: ./orgfront - dockerfile: Dockerfile - container_name: baron_orgfront - env_file: - - .env - environment: - - APP_ENV=${APP_ENV:-development} - - API_PROXY_TARGET=http://baron_backend:3000 - - USERFRONT_URL=${USERFRONT_URL} - ports: - - "${ORGFRONT_PORT:-5175}:5175" - volumes: - - ./orgfront:/app - - /app/node_modules - networks: - - baron_net + devfront: + build: + context: ./devfront + dockerfile: Dockerfile + container_name: baron_devfront + env_file: + - .env + environment: + - APP_ENV=${APP_ENV:-development} + - API_PROXY_TARGET=http://baron_backend:3000 + ports: + - "${DEVFRONT_PORT:-5174}:5173" + volumes: + - ./devfront:/app + - /app/node_modules + networks: + - baron_net - userfront: - build: - context: . - dockerfile: userfront/Dockerfile - container_name: baron_userfront - env_file: - - .env - environment: - - BACKEND_URL=${BACKEND_URL:-} - - USERFRONT_URL=${USERFRONT_URL} - - APP_ENV=${APP_ENV} - networks: - - baron_net - - ory-net - depends_on: - backend: - condition: service_healthy - command: > - /bin/sh -c "mkdir -p /usr/share/nginx/html/assets && - echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env && - echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env && - echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env && - cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env && - nginx -g 'daemon off;'" - healthcheck: - test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 10s + orgfront: + build: + context: ./orgfront + dockerfile: Dockerfile + container_name: baron_orgfront + env_file: + - .env + environment: + - APP_ENV=${APP_ENV:-development} + - API_PROXY_TARGET=http://baron_backend:3000 + - USERFRONT_URL=${USERFRONT_URL} + ports: + - "${ORGFRONT_PORT:-5175}:5175" + volumes: + - ./orgfront:/app + - /app/node_modules + networks: + - baron_net - infra_check: - image: alpine - command: ["echo", "Infrastructure assumed running"] - networks: - - baron_net + userfront: + build: + context: . + dockerfile: userfront/Dockerfile + container_name: baron_userfront + env_file: + - .env + environment: + - BACKEND_URL=${BACKEND_URL:-} + - USERFRONT_URL=${USERFRONT_URL} + - APP_ENV=${APP_ENV} + networks: + - baron_net + - ory-net + depends_on: + backend: + condition: service_healthy + command: > + /bin/sh -c "mkdir -p /usr/share/nginx/html/assets && + echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env && + echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env && + echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env && + cp /usr/share/nginx/html/assets/.env /usr/share/nginx/html/.env && + nginx -g 'daemon off;'" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + + infra_check: + image: alpine + command: ["echo", "Infrastructure assumed running"] + networks: + - baron_net volumes: - postgres_data: - clickhouse_data: - redis_data: - ory_postgres_data: - ory_clickhouse_data: - oathkeeper_logs: + postgres_data: + clickhouse_data: + redis_data: + ory_postgres_data: + ory_clickhouse_data: + oathkeeper_logs: networks: - baron_net: - external: true - name: baron_net - public_net: - external: true - name: public_net - ory-net: - external: true - name: ory-net - hydranet: - external: true - name: hydranet - kratosnet: - external: true - name: kratosnet + baron_net: + external: true + name: baron_net + public_net: + external: true + name: public_net + ory-net: + external: true + name: ory-net + hydranet: + external: true + name: hydranet + kratosnet: + external: true + name: kratosnet diff --git a/docs/devfront_auth_flow_explanation.md b/docs/devfront_auth_flow_explanation.md index 9b9a17fa..46b86dff 100644 --- a/docs/devfront_auth_flow_explanation.md +++ b/docs/devfront_auth_flow_explanation.md @@ -23,7 +23,7 @@ sequenceDiagram UF->>HY: 로그인 승인 요청 HY->>User: 권한 동의(Consent) 화면 표시 User->>HY: '허용' 클릭 - HY-->>DF: 인증 코드와 함께 리다이렉트 (/callback?code=...) + HY-->>DF: 인증 코드와 함께 리다이렉트 (/auth/callback?code=...) DF->>HY: 토큰 교환 요청 (Code -> ID/Access Token) HY-->>DF: 토큰 발급 Note over DF: [FIX] 백엔드 /api/me 호출 대신
ID Token에서 프로필 정보 직접 추출 @@ -46,7 +46,7 @@ sequenceDiagram * 사용자가 '허용'을 누르면 Hydra는 `devfront`가 신뢰할 수 있는 앱임을 기록합니다. 4. **인증 코드 전달 및 토큰 교환 (Callback)**: - * Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/callback?code=...`)로 보냅니다. + * Hydra는 사용자를 `devfront`의 콜백 페이지(`http://localhost:5174/auth/callback?code=...`)로 보냅니다. * `devfront`는 이 코드를 Hydra의 토큰 엔드포인트로 보내 **ID Token**과 **Access Token**을 발급받습니다. 5. **사용자 정보 로드 (Profile Recovery)**: @@ -71,9 +71,9 @@ hydra clients create --response-types code --scope openid,offline_access,profile,email --token-endpoint-auth-method none \ # Public Client (PKCE 사용) - --callbacks http://localhost:5174/callback; + --callbacks http://localhost:5174/auth/callback; ``` -이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174`로의 리다이렉션이 안전하게 허용됩니다. +이 설정으로 인해 `devfront`라는 ID의 클라이언트가 미리 존재하게 되며, `localhost:5174/auth/callback`으로의 리다이렉션이 안전하게 허용됩니다. --- diff --git a/docs/oidc_redirect_mapping_validation_policy.md b/docs/oidc_redirect_mapping_validation_policy.md index f71a1680..d46fdfc9 100644 --- a/docs/oidc_redirect_mapping_validation_policy.md +++ b/docs/oidc_redirect_mapping_validation_policy.md @@ -7,7 +7,8 @@ ## 적용 범위 - UserFront, AdminFront, DevFront의 로그인/콜백 경로 - Ory Stack(Hydra/Kratos/Oathkeeper) 설정 -- `compose.ory.yaml`, `gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json` +- `compose.ory.yaml`, `docker/compose.ory.yaml`, `docker/staging_pull_compose.template.yaml` +- `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json` - `Makefile` 기반 사전 검증/스모크 검증 단계 ## 핵심 원칙 @@ -27,8 +28,8 @@ 2. `mapped_match` - Public URL과 Internal URL이 다르지만, 아래가 모두 성립 -- Gateway 라우팅 규칙 존재 (예: `/oidc` rewrite) -- Oathkeeper `match`와 `upstream` 규칙 존재 (예: `strip_path_prefix=/oidc`) +- Gateway 라우팅 규칙 존재: `/oidc` prefix를 제거하지 않고 Oathkeeper로 전달 +- Oathkeeper `match`와 `upstream` 규칙 존재: `/oidc/*` rule이 `strip_path=/oidc`로 Hydra에 전달 - 최종 업스트림이 기대 서비스(Hydra/Kratos)로 연결 3. `unmapped_fail` @@ -42,6 +43,8 @@ - `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS` URL 유효성/중복/경로 규약 - Gateway `/oidc`, `/auth` 라우팅 규칙 존재 여부 - Oathkeeper `rules*.json`의 Hydra/Kratos 매핑 규칙 존재 여부 +- staging pull/deploy template의 Oathkeeper entrypoint 사용 여부 +- `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인, locale path, callback/return path가 포함되는지 여부 2. 런타임 검증 (`make verify-oidc-config`) - OIDC Discovery endpoint 조회 가능 여부 @@ -49,9 +52,38 @@ - 필요 시 Gateway 경유 endpoint probe로 매핑 체인 확인 ## 경로 규약 -- DevFront callback: `/callback` +- DevFront callback: `/auth/callback` - AdminFront callback: `/auth/callback` +- OrgFront callback: `/auth/callback` - UserFront OIDC 진입점: `/oidc/*` (Gateway 경유) +- locale return path: `/ko`, `/en`, `/ko/auth/callback`, `/en/auth/callback` + +## `/oidc` 책임 경계 +- Gateway는 `/oidc` prefix를 보존합니다. +- Oathkeeper는 `/oidc/.well-known/*`, `/oidc/oauth2/*`, `/oidc/userinfo` rule에서 `strip_path=/oidc`를 적용합니다. +- Hydra는 prefix가 제거된 내부 경로(`/.well-known/*`, `/oauth2/*`, `/userinfo`)를 받습니다. +- 따라서 gateway template이나 staging pull compose에서 `rewrite ^/oidc`가 다시 들어가면 dev/stage/prod 간 책임 경계가 달라지므로 실패로 간주합니다. + +## Oathkeeper rules 선택 정책 +- Oathkeeper는 직접 `command: serve proxy ...`로 시작하지 않고 `/etc/config/oathkeeper/entrypoint.sh`를 통해 시작합니다. +- entrypoint는 `APP_ENV`에 따라 다음 파일을 선택하고 `/tmp/oathkeeper/rules.active.json`으로 복사합니다. + - `stage|staging`: `rules.stage.json` + - `production|prod`: `rules.prod.json` + - 그 외: `rules.json` +- `oathkeeper.yml`은 `file:///tmp/oathkeeper/rules.active.json`만 읽습니다. + +## Kratos allowed return URL 정책 +- stage/prod에서는 `KRATOS_ALLOWED_RETURN_URLS_JSON`을 명시하는 것을 우선합니다. +- 최소 포함 대상: + - `KRATOS_UI_URL`, `KRATOS_UI_URL/` + - `USERFRONT_URL`, `USERFRONT_URL/` + - `USERFRONT_URL/ko`, `USERFRONT_URL/ko/` + - `USERFRONT_URL/en`, `USERFRONT_URL/en/` + - `USERFRONT_URL/auth/callback` + - `USERFRONT_URL/ko/auth/callback` + - `USERFRONT_URL/en/auth/callback` + - `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS`, `ORGFRONT_CALLBACK_URLS` +- private IP, legacy domain, comma-space가 포함된 URI 항목은 stage/prod 기본값으로 두지 않습니다. ## 운영 지침 1. 환경별 URL은 동일할 필요가 없고, 매핑 체인이 검증 가능해야 합니다. @@ -65,3 +97,4 @@ - #272 - #274 - #276 +- #710 diff --git a/docs/ory-usage.md b/docs/ory-usage.md index e0c6d37f..4f2de115 100644 --- a/docs/ory-usage.md +++ b/docs/ory-usage.md @@ -33,12 +33,14 @@ Ory 구성은 **컨테이너 내부 통신 URL**과 **브라우저 접근 URL** ### 내부 통신용 URL(컨테이너 네트워크) - `KRATOS_PUBLIC_URL=http://kratos:4433` - `KRATOS_ADMIN_URL=http://kratos:4434` -- `HYDRA_PUBLIC_URL=http://hydra:4444` - `HYDRA_ADMIN_URL=http://hydra:4445` +- Hydra public upstream은 Oathkeeper rule 내부에서 `http://hydra:4444`로 전달합니다. ### 브라우저 접근용 URL(외부 도메인/프록시) -- `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL +- `KRATOS_BROWSER_URL` : Kratos Public의 외부 URL. 보통 `${OATHKEEPER_PUBLIC_URL}/auth` - `KRATOS_UI_URL` : UserFront의 외부 URL (Kratos UI 역할) +- `HYDRA_PUBLIC_URL` : Hydra issuer/OIDC discovery의 외부 URL. 보통 `${OATHKEEPER_PUBLIC_URL}/oidc` +- `VITE_OIDC_AUTHORITY` : 프론트엔드 OIDC authority. `HYDRA_PUBLIC_URL`과 같아야 합니다. 예시(로컬): ```env @@ -48,8 +50,11 @@ KRATOS_UI_URL=http://localhost:5000 예시(리버스 프록시/도메인): ```env -KRATOS_BROWSER_URL=https://sso.example.com +OATHKEEPER_PUBLIC_URL=https://sso.example.com +KRATOS_BROWSER_URL=https://sso.example.com/auth KRATOS_UI_URL=https://sso.example.com +HYDRA_PUBLIC_URL=https://sso.example.com/oidc +VITE_OIDC_AUTHORITY=https://sso.example.com/oidc ``` ### 포트 노출 정책 @@ -64,6 +69,7 @@ Kratos는 self-service UI URL을 설정값으로 사용합니다. **UserFront의 - `KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL` - `KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS` - `KRATOS_SELFSERVICE_FLOWS_*_UI_URL` +- `KRATOS_ALLOWED_RETURN_URLS_JSON` compose에서 기본적으로 다음과 같이 오버라이드합니다: - `KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login` @@ -72,18 +78,44 @@ compose에서 기본적으로 다음과 같이 오버라이드합니다: - `KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery` - `KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification` -## 5) 트러블슈팅 -### 5.1 로그인 클릭 시 동작 없음 +stage/prod에서는 `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인과 callback/locale 경로를 명시합니다. + +필수 후보: +- `${KRATOS_UI_URL}`, `${KRATOS_UI_URL}/` +- `${USERFRONT_URL}`, `${USERFRONT_URL}/` +- `${USERFRONT_URL}/ko`, `${USERFRONT_URL}/ko/` +- `${USERFRONT_URL}/en`, `${USERFRONT_URL}/en/` +- `${USERFRONT_URL}/auth/callback` +- `${USERFRONT_URL}/ko/auth/callback` +- `${USERFRONT_URL}/en/auth/callback` +- `${ADMINFRONT_CALLBACK_URLS}`, `${DEVFRONT_CALLBACK_URLS}`, `${ORGFRONT_CALLBACK_URLS}` + +## 5) `/oidc` Gateway/Oathkeeper 책임 경계 +Gateway는 `/oidc` prefix를 rewrite하지 않습니다. `/oidc/*` 요청은 prefix를 보존한 채 Oathkeeper로 전달하고, Oathkeeper rule이 `strip_path=/oidc`로 Hydra 내부 upstream(`http://hydra:4444`)에 전달합니다. + +이 정책은 `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`, `docker/staging_pull_compose.template.yaml`에서 동일해야 합니다. + +## 6) Oathkeeper active rules +Oathkeeper는 `/etc/config/oathkeeper/entrypoint.sh`를 통해 시작해야 합니다. entrypoint는 `APP_ENV`에 따라 env별 rules 파일을 고르고 `/tmp/oathkeeper/rules.active.json`을 생성합니다. + +- `APP_ENV=stage|staging`: `rules.stage.json` +- `APP_ENV=production|prod`: `rules.prod.json` +- 그 외: `rules.json` + +`docker/ory/oathkeeper/oathkeeper.yml`은 `file:///tmp/oathkeeper/rules.active.json`을 읽습니다. compose나 배포 템플릿이 entrypoint를 우회해 `oathkeeper serve proxy`를 직접 실행하면 active rules 생성이 누락될 수 있습니다. + +## 7) 트러블슈팅 +### 7.1 로그인 클릭 시 동작 없음 - 원인: Kratos 기동 실패(설정 파싱 실패 등) 또는 브라우저용 URL이 내부 도메인(`kratos:...`)으로 설정됨 - 확인: - `docker logs ory_kratos`에서 config 오류 여부 확인 - 브라우저 네트워크 탭에서 `/self-service/login/browser` 응답 확인(302 Location 헤더) -### 5.2 kratos.yml에 ${...} 환경 변수 치환 실패 +### 7.2 kratos.yml에 ${...} 환경 변수 치환 실패 - Kratos 설정 파일은 `${ENV}` 치환을 지원하지 않음 - 해결: compose 환경 변수로 `KRATOS_SELFSERVICE_*`, `KRATOS_SERVE_*` 오버라이드 사용 -## 6) 네트워크 접근 테스트 +## 8) 네트워크 접근 테스트 아래 스크립트는 **ory-net에서 Admin 포트 접근 가능** / **baron_net(Frontend 영역)에서 접근 불가**를 검증합니다. ```bash @@ -101,7 +133,7 @@ docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://hydra:444 docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:4434/health/ready ``` -## 7) 참고 파일 +## 9) 참고 파일 - `compose.ory.yaml` - `docker/ory/kratos/kratos.yml` - `.env.sample` diff --git a/gateway/nginx.conf b/gateway/nginx.conf index a113d968..2bc97d70 100644 --- a/gateway/nginx.conf +++ b/gateway/nginx.conf @@ -61,7 +61,6 @@ server { # Hydra Public API location /oidc { - rewrite ^/oidc/(.*)$ /$1 break; proxy_pass $oathkeeper_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh index 735320ed..544638ac 100755 --- a/scripts/auth_config.sh +++ b/scripts/auth_config.sh @@ -16,11 +16,12 @@ fi USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}" OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}" HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}" +HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}" DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}" -ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://172.16.10.176:5173/auth/callback}" -DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://172.16.10.176:5174/auth/callback}" +ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-${ADMINFRONT_URL%/}/auth/callback}" +DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-${DEVFRONT_URL%/}/auth/callback}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" declare -a WARNINGS=() @@ -258,13 +259,10 @@ validate_gateway_mapping() { if ! grep -Eq 'location /oidc' "$ROOT_DIR/gateway/nginx.conf"; then mode="unmapped_fail" fi - if ! grep -Eq 'rewrite \^/oidc/\(\.\*\)\$ /\$1 break;' "$ROOT_DIR/gateway/nginx.conf"; then + if ! grep -Eq '"url": "<\.\*>://<(\.\*|\[\^/\]\+)>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then mode="unmapped_fail" fi - if ! grep -Eq '"url": "<\.\*>://<\.\*>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then - mode="unmapped_fail" - fi - if ! grep -Eq '"strip_path_prefix": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then + if ! grep -Eq '"strip_path(_prefix)?": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then mode="unmapped_fail" fi fi @@ -358,10 +356,10 @@ verify_runtime_hydra_clients() { fi local admin_info dev_info - if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 adminfront 2>/dev/null)"; then + if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" adminfront 2>/dev/null)"; then fail "failed to read hydra client 'adminfront' from running container" fi - if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 devfront 2>/dev/null)"; then + if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" devfront 2>/dev/null)"; then fail "failed to read hydra client 'devfront' from running container" fi @@ -382,6 +380,7 @@ run_validation() { validate_dotenv_line_safety "BACKEND_URL" validate_dotenv_line_safety "OATHKEEPER_PUBLIC_URL" validate_dotenv_line_safety "HYDRA_PUBLIC_URL" + validate_dotenv_line_safety "HYDRA_ADMIN_URL" validate_dotenv_line_safety "KRATOS_BROWSER_URL" validate_dotenv_line_safety "KRATOS_UI_URL" validate_dotenv_line_safety "ADMINFRONT_URL" diff --git a/test/make_dev_targets_test.sh b/test/make_dev_targets_test.sh index 92a02b75..367574ef 100644 --- a/test/make_dev_targets_test.sh +++ b/test/make_dev_targets_test.sh @@ -3,6 +3,19 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +dry_run_default_dev="$( + make --dry-run --always-make -C "$repo_root" dev 2>&1 +)" + +default_app_up_line="$( + grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront.*devfront.*orgfront.*userfront" <<<"$dry_run_default_dev" | tail -1 +)" + +if [[ -z "$default_app_up_line" ]]; then + echo "make dev must include orgfront in the default development app services." >&2 + exit 1 +fi + dry_run_dev="$( make --dry-run --always-make -C "$repo_root" dev DEV_SERVICES="backend adminfront" 2>&1 )" @@ -45,10 +58,32 @@ if ! grep -q "Ensuring Ory stack" <<<"$dry_run_up_dev"; then exit 1 fi +dry_run_up_app="$( + make --dry-run --always-make -C "$repo_root" up-app 2>&1 +)" + +if ! grep -q "Starting App stack (backend/userfront/adminfront/devfront/orgfront)" <<<"$dry_run_up_app"; then + echo "make up-app must announce orgfront as part of the app stack." >&2 + exit 1 +fi + dry_run_up_all="$( make --dry-run --always-make -C "$repo_root" up-all 2>&1 )" +if ! dry_run_up="$( + make --dry-run --always-make -C "$repo_root" up 2>&1 +)"; then + echo "make up must be available as the default full-stack startup target." >&2 + echo "$dry_run_up" >&2 + exit 1 +fi + +if ! grep -q "Starting ALL stacks (infra + ory + app)" <<<"$dry_run_up"; then + echo "make up must delegate to the full-stack startup flow." >&2 + exit 1 +fi + if ! grep -q "Ensuring Docker networks" <<<"$dry_run_up_all"; then echo "make up-all must ensure external Docker networks before compose up." >&2 exit 1 diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh index 5c1d3a29..df6be834 100644 --- a/test/ory_v26_compose_policy_test.sh +++ b/test/ory_v26_compose_policy_test.sh @@ -10,6 +10,26 @@ docker_config="$( docker compose --env-file "$repo_root/.env" -f "$repo_root/docker/compose.ory.yaml" config )" +override_env="$(mktemp)" +cp "$repo_root/.env" "$override_env" +cat >> "$override_env" <<'EOF' +USERFRONT_URL=https://compose-policy.example.test/sso +HYDRA_PUBLIC_URL=https://compose-policy.example.test/sso/oidc +KRATOS_UI_URL=https://compose-policy.example.test/ui +KRATOS_BROWSER_URL=https://compose-policy.example.test/auth +ADMINFRONT_CALLBACK_URLS=https://compose-policy.example.test/admin/callback +DEVFRONT_CALLBACK_URLS=https://compose-policy.example.test/dev/callback +ORGFRONT_CALLBACK_URLS=https://compose-policy.example.test/org/callback +EOF +trap 'rm -f "$override_env"' EXIT + +override_config="$( + docker compose --env-file "$override_env" -f "$repo_root/compose.ory.yaml" config +)" +override_docker_config="$( + docker compose --env-file "$override_env" -f "$repo_root/docker/compose.ory.yaml" config +)" + for service in kratos hydra keto oathkeeper; do version_key="$(tr '[:lower:]' '[:upper:]' <<<"$service")_VERSION" expected_version="$(grep -E "^${version_key}=" "$repo_root/.env" | cut -d= -f2-)" @@ -28,6 +48,40 @@ if grep -q "oryd/hydra:v25.4.0" <<<"$root_config"; then exit 1 fi +for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory.yaml"; do + if grep -Eq 'redirect-uri .*:-.*https?://' "$compose_file"; then + echo "ERROR: $compose_file must not hard-code external redirect URI fallbacks; use .env variables." >&2 + exit 1 + fi + if grep -Eq 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=.*https?://localhost' "$compose_file"; then + echo "ERROR: $compose_file must not hard-code Kratos allowed return URL fallbacks; use .env variables." >&2 + exit 1 + fi + if awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$compose_file" | grep -q -- '--endpoint http://hydra:4445'; then + echo "ERROR: $compose_file init-rp must use HYDRA_ADMIN_URL instead of hard-coded Hydra admin endpoint." >&2 + exit 1 + fi + if awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^[[:space:]]+oathkeeper:/ { in_block=1 } in_block { print }' "$compose_file" | grep -q "command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml"; then + echo "ERROR: $compose_file Oathkeeper must use entrypoint.sh instead of bypassing rules.active.json generation." >&2 + exit 1 + fi +done + +for expected_url in \ + "https://compose-policy.example.test/sso/oidc" \ + "https://compose-policy.example.test/sso/login" \ + "https://compose-policy.example.test/sso/consent" \ + "https://compose-policy.example.test/sso/error" \ + "https://compose-policy.example.test/admin/callback" \ + "https://compose-policy.example.test/dev/callback" \ + "https://compose-policy.example.test/org/callback" +do + if ! grep -q "$expected_url" <<<"$override_config$override_docker_config"; then + echo "ERROR: Ory compose config must render env override URL: $expected_url" >&2 + exit 1 + fi +done + root_init_rp="$( awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/compose.ory.yaml" )" @@ -53,3 +107,159 @@ if grep -q "releases/download/v25.4.0" "$repo_root/docker/staging_pull_compose.t echo "ERROR: staging pull compose must not download a hard-coded Hydra v25.4.0 CLI." >&2 exit 1 fi + +staging_pull_template="$repo_root/docker/staging_pull_compose.template.yaml" + +if ! grep -q 'entrypoint: \["/etc/config/oathkeeper/entrypoint.sh"\]' "$staging_pull_template"; then + echo "ERROR: staging pull Oathkeeper must use the env-aware entrypoint." >&2 + exit 1 +fi + +if grep -q "command: serve proxy --config /etc/config/oathkeeper/oathkeeper.yml" "$staging_pull_template"; then + echo "ERROR: staging pull Oathkeeper must not bypass entrypoint.sh with a direct command." >&2 + exit 1 +fi + +if ! grep -q "URLS_SELF_ISSUER=\${HYDRA_PUBLIC_URL}" "$staging_pull_template"; then + echo "ERROR: staging pull Hydra issuer must use HYDRA_PUBLIC_URL." >&2 + exit 1 +fi + +if grep -Eq '(KRATOS_(SERVE|SELFSERVICE|UI|BROWSER|PUBLIC|ADMIN).*:-http://localhost|URLS_.*:-http://localhost)' "$staging_pull_template"; then + echo "ERROR: staging pull Ory browser URLs must not fall back to localhost." >&2 + exit 1 +fi + +if ! grep -q 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON' "$staging_pull_template"; then + echo "ERROR: staging pull Kratos allowed_return_urls must be driven by KRATOS_ALLOWED_RETURN_URLS_JSON." >&2 + exit 1 +fi + +for return_path in '/ko' '/en' '/auth/callback' '/ko/auth/callback' '/en/auth/callback'; do + if ! grep -q "$return_path" "$staging_pull_template" "$repo_root/deploy/templates/.env.template" "$repo_root/.gitea/workflows/staging_code_pull.yml"; then + echo "ERROR: staging/prod allowed_return_urls must include locale/callback path: $return_path" >&2 + exit 1 + fi +done + +if grep -Eq 'ORGFRONT_CALLBACK_URLS=.*(172\.16\.10\.176|baron-orgchart\.hmac\.kr|, https?://)' "$staging_pull_template" "$repo_root/.gitea/workflows/staging_code_pull.yml"; then + echo "ERROR: staging pull OrgFront callbacks must not keep private IP, legacy orgchart domain, or comma-space URI entries." >&2 + exit 1 +fi + +if grep -q "rewrite \\^/oidc" "$repo_root/gateway/nginx.conf"; then + echo "ERROR: gateway must preserve the /oidc prefix and let Oathkeeper strip it." >&2 + exit 1 +fi + +for rules_file in \ + "$repo_root/docker/ory/oathkeeper/rules.json" \ + "$repo_root/docker/ory/oathkeeper/rules.stage.json" \ + "$repo_root/docker/ory/oathkeeper/rules.prod.json" +do + for rule_id in hydra-well-known hydra-well-known-oidc hydra-oauth2 hydra-oauth2-oidc hydra-userinfo hydra-userinfo-oidc; do + if ! grep -q "\"id\": \"$rule_id\"" "$rules_file"; then + echo "ERROR: Oathkeeper rules must expose Hydra public route in $rules_file: $rule_id" >&2 + exit 1 + fi + done + for prefixed_rule in hydra-well-known-oidc hydra-oauth2-oidc hydra-userinfo-oidc; do + if ! awk -v id="\"id\": \"$prefixed_rule\"" ' + $0 ~ id { in_rule = 1 } + in_rule && /strip_path/ && /\/oidc/ { found = 1 } + in_rule && /^ }[,]?$/ { in_rule = 0 } + END { exit found ? 0 : 1 } + ' "$rules_file"; then + echo "ERROR: prefixed Oathkeeper route must strip /oidc in $rules_file: $prefixed_rule" >&2 + exit 1 + fi + done +done + +for wildcard_rules_file in \ + "$repo_root/docker/ory/oathkeeper/rules.json" \ + "$repo_root/docker/ory/oathkeeper/rules.stage.json" +do + if grep -q "<\\.\\*>://<\\.\\*>/" "$wildcard_rules_file"; then + echo "ERROR: wildcard Oathkeeper host must not swallow path segments in $wildcard_rules_file." >&2 + exit 1 + fi +done + +deploy_template="$repo_root/deploy/templates/docker-compose.yaml" +deploy_env_template="$repo_root/deploy/templates/.env.template" +deploy_gateway_template="$repo_root/deploy/templates/gateway/nginx.conf" +deploy_kratos_template="$repo_root/deploy/templates/ory/kratos/kratos.yml" +deploy_oathkeeper_rules_template="$repo_root/deploy/templates/ory/oathkeeper/rules.json" + +for required_template in \ + "$repo_root/deploy/templates/orgfront/vite.config.ts" \ + "$repo_root/deploy/templates/orgfront/auth.ts" \ + "$repo_root/docker/ory/init-db/01_create_dbs.sh" \ + "$repo_root/docker/ory/hydra/hydra.yml" \ + "$repo_root/docker/ory/keto/keto.yml" \ + "$repo_root/docker/ory/oathkeeper/entrypoint.sh" \ + "$repo_root/docker/ory/oathkeeper/oathkeeper.yml" +do + if [[ ! -f "$required_template" ]]; then + echo "ERROR: deploy instance generation requires missing source file: $required_template" >&2 + exit 1 + fi +done + +if grep -Eq "oryd/(kratos|hydra|keto|oathkeeper):v25\\.4\\.0" "$deploy_template"; then + echo "ERROR: deploy template Ory stack must not hard-code v25.4.0 images." >&2 + exit 1 +fi + +for prod_sensitive_file in \ + "$repo_root/docker/ory/oathkeeper/rules.prod.json" \ + "$repo_root/docker/ory/kratos/kratos.yml" \ + "$repo_root/deploy/templates/ory/kratos/kratos.yml" +do + if grep -q "app\\.brsw\\.kr" "$prod_sensitive_file"; then + echo "ERROR: Ory production-sensitive config must not hard-code app.brsw.kr: $prod_sensitive_file" >&2 + exit 1 + fi +done + +for service in kratos-migrate kratos hydra-migrate hydra keto-migrate keto oathkeeper_logs_init oathkeeper; do + if ! grep -q "^ $service:" "$deploy_template"; then + echo "ERROR: deploy template Ory stack must include service: $service" >&2 + exit 1 + fi +done + +for version_key in KRATOS_VERSION HYDRA_VERSION KETO_VERSION OATHKEEPER_VERSION; do + if ! grep -q "^$version_key=v26\\.2\\.0$" "$deploy_env_template"; then + echo "ERROR: deploy env template must define $version_key=v26.2.0." >&2 + exit 1 + fi +done + +if ! grep -q 'entrypoint: \["/etc/config/oathkeeper/entrypoint.sh"\]' "$deploy_template"; then + echo "ERROR: deploy template Oathkeeper must use the env-aware entrypoint." >&2 + exit 1 +fi + +if grep -q "rewrite \\^/oidc" "$deploy_gateway_template"; then + echo "ERROR: deploy template gateway must preserve the /oidc prefix." >&2 + exit 1 +fi + +if ! grep -q '^version: v26.2.0$' "$deploy_kratos_template"; then + echo "ERROR: deploy Kratos template config version must match v26.2.0." >&2 + exit 1 +fi + +for rule_id in hydra-well-known hydra-well-known-oidc hydra-oauth2 hydra-oauth2-oidc hydra-userinfo hydra-userinfo-oidc; do + if ! grep -q "\"id\": \"$rule_id\"" "$deploy_oathkeeper_rules_template"; then + echo "ERROR: deploy Oathkeeper rules must expose Hydra public route: $rule_id" >&2 + exit 1 + fi +done + +if ! grep -q '"strip_path": "/oidc"' "$deploy_oathkeeper_rules_template"; then + echo "ERROR: deploy Oathkeeper prefixed routes must strip /oidc with strip_path." >&2 + exit 1 +fi From 2cba9c9c1fe4a8f81cdf03fc78e1b7f241fd7399 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 7 May 2026 11:01:25 +0900 Subject: [PATCH 04/15] =?UTF-8?q?go=20=EB=B2=84=EC=A0=84=EC=97=85=20&&=20o?= =?UTF-8?q?ry=20=EC=84=A4=EC=A0=95=ED=8C=8C=EC=9D=BC=EB=93=A4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/staging_code_pull.yml | 5 +- .gitignore | 1 + Makefile | 28 ++--- README.md | 10 +- README_en.md | 2 +- backend/Dockerfile | 2 +- backend/go.mod | 2 +- compose.ory.yaml | 36 +++++-- deploy/create-instance.sh | 37 ++++--- deploy/templates/docker-compose.yaml | 36 +++++-- .../{kratos.yml => kratos.yml.template} | 50 ++++----- docker/compose.ory.yaml | 36 +++++-- .../hydra/{hydra.yml => hydra.yml.template} | 4 +- .../ory/keto/{keto.yml => keto.yml.template} | 2 +- .../{kratos.yml => kratos.yml.template} | 56 +++++----- ...oathkeeper.yml => oathkeeper.yml.template} | 0 docker/staging_pull_compose.template.yaml | 36 +++++-- docs/TEST_GUIDE.md | 2 +- docs/개발완료보고서.md | 2 +- scripts/auth_config.sh | 2 +- scripts/render_ory_config.sh | 100 ++++++++++++++++++ test/backend_go_version_policy_test.sh | 78 ++++++++++++++ test/make_dev_targets_test.sh | 34 ++++++ test/ory_log_pipeline_policy_test.sh | 6 +- test/ory_v26_compose_policy_test.sh | 81 ++++++++++++-- 25 files changed, 504 insertions(+), 144 deletions(-) rename deploy/templates/ory/kratos/{kratos.yml => kratos.yml.template} (58%) rename docker/ory/hydra/{hydra.yml => hydra.yml.template} (97%) rename docker/ory/keto/{keto.yml => keto.yml.template} (91%) rename docker/ory/kratos/{kratos.yml => kratos.yml.template} (58%) rename docker/ory/oathkeeper/{oathkeeper.yml => oathkeeper.yml.template} (100%) create mode 100755 scripts/render_ory_config.sh create mode 100644 test/backend_go_version_policy_test.sh diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml index 8e5f2f8a..4e1a6233 100644 --- a/.gitea/workflows/staging_code_pull.yml +++ b/.gitea/workflows/staging_code_pull.yml @@ -163,8 +163,9 @@ jobs: done - # [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨) - chmod -R 777 docker/ory || true + # Ory 컨테이너가 직접 읽는 설정은 env 기반으로 완성한 뒤 mount합니다. + bash scripts/render_ory_config.sh + chmod -R 777 config/.generated/ory || true cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml diff --git a/.gitignore b/.gitignore index 1a5c7519..a4f12e27 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .codex/ .serena/ .generated/ +config/.generated/ *.swp *.log *.out diff --git a/Makefile b/Makefile index f4bd4fa0..df61eac0 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ endif COMPOSE_INFRA := compose.infra.yaml COMPOSE_ORY := compose.ory.yaml COMPOSE_APP := docker-compose.yaml -AUTH_CONFIG_ENV := .generated/auth-config.env +AUTH_CONFIG_ENV := config/.generated/auth-config.env DEV_SERVICES ?= backend adminfront devfront orgfront userfront DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway @@ -29,12 +29,12 @@ ifneq (,$(wildcard ./.env)) COMPOSE_DROP_ENV_ARGS += --env-file .env endif -.PHONY: build-auth-config validate-auth-config verify-auth-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app +.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app # --- 인증 설정 빌드/검증 --- build-auth-config: @echo "Building auth config..." - @mkdir -p .generated + @mkdir -p config/.generated @bash scripts/auth_config.sh build validate-auth-config: build-auth-config @@ -45,30 +45,34 @@ verify-auth-config: validate-auth-config @echo "Verifying auth config wiring..." @bash scripts/auth_config.sh verify +render-ory-config: validate-auth-config + @echo "Rendering Ory config..." + @bash scripts/render_ory_config.sh + # --- 기본 실행 --- # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) up: up-all -up-all: ensure-networks validate-auth-config +up-all: ensure-networks render-ory-config @echo "Starting ALL stacks (infra + ory + app)..." - docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d # --- 개별 스택 실행 --- up-infra: ensure-networks @echo "Starting Infra stack (postgres/clickhouse/redis)..." docker compose -f $(COMPOSE_INFRA) up -d -up-ory: ensure-networks validate-auth-config +up-ory: ensure-networks render-ory-config @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d -up-app: ensure-networks validate-auth-config +up-app: ensure-networks render-ory-config @echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..." - docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d -up-backend: ensure-networks validate-auth-config +up-backend: ensure-networks render-ory-config @echo "Starting Backend only..." - docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend ensure-networks: @echo "Ensuring Docker networks..." @@ -97,7 +101,7 @@ ensure-infra: ensure-networks echo "Infra stack is already running."; \ fi -ensure-ory: ensure-networks validate-auth-config +ensure-ory: ensure-networks render-ory-config @echo "Ensuring Ory stack..." @missing=0; \ for container in $(ORY_CONTAINERS); do \ @@ -121,7 +125,7 @@ up-front-dev: up-infra up-ory up-backend dev: up-dev @echo "Starting development app containers in foreground attach mode..." - docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up $(DEV_SERVICES) + docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) # --- 종료 (Down) --- down: diff --git a/README.md b/README.md index dd0385bb..a9559c83 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ flowchart ``` ### 1. Backend (Go Fiber) -- **Language**: Go 1.25+ +- **Language**: Go 1.26.2+ - **Framework**: Fiber v2.25+ - **Database**: - **ClickHouse**: 감사 로그 (고성능 데이터 수집) @@ -436,7 +436,7 @@ USERFRONT_URL=https://sso.example.com ```bash make validate-auth-config ``` -위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다. +위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다. ### 전체 스택 실행 (Running the Stack) @@ -478,7 +478,7 @@ make validate-auth-config make verify-auth-config ``` -- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입) +- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입) - 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다. - 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md` @@ -493,8 +493,8 @@ make up-app 직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요. ```bash -docker compose --env-file .env --env-file .generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d -docker compose --env-file .env --env-file .generated/auth-config.env -f docker-compose.yaml up -d +docker compose --env-file .env --env-file config/.generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d +docker compose --env-file .env --env-file config/.generated/auth-config.env -f docker-compose.yaml up -d ``` - **gateway (UserFront 프록시)**: http://localhost:5000 접속 diff --git a/README_en.md b/README_en.md index 5ba1130b..79fba211 100644 --- a/README_en.md +++ b/README_en.md @@ -14,7 +14,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link - Descope SDK Integration (Enchanted Link, Magic Link) ### 2. Backend (Go Fiber) -- **Language**: Go 1.25+ +- **Language**: Go 1.26.2+ - **Framework**: Fiber v2.25+ - **Database**: - **ClickHouse**: Audit Logs (High performance ingestion) diff --git a/backend/Dockerfile b/backend/Dockerfile index 3c2fbf7e..e72ea159 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25-alpine +FROM golang:1.26.2-alpine WORKDIR /app diff --git a/backend/go.mod b/backend/go.mod index fe7a265b..cb9b8736 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module baron-sso-backend -go 1.25.4 +go 1.26.2 require ( github.com/ClickHouse/clickhouse-go/v2 v2.42.0 diff --git a/compose.ory.yaml b/compose.ory.yaml index 99213589..2672ddde 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -38,7 +38,7 @@ services: - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - - ./docker/ory/kratos:/etc/config/kratos + - ./config/.generated/ory/kratos:/etc/config/kratos command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes depends_on: postgres_ory: @@ -64,7 +64,7 @@ services: - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - - ./docker/ory/kratos:/etc/config/kratos + - ./config/.generated/ory/kratos:/etc/config/kratos command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier depends_on: kratos-migrate: @@ -96,7 +96,7 @@ services: - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} volumes: - - ./docker/ory/hydra:/etc/config/hydra + - ./config/.generated/ory/hydra:/etc/config/hydra command: serve -c /etc/config/hydra/hydra.yml all --dev depends_on: hydra-migrate: @@ -111,7 +111,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./docker/ory/keto:/etc/config/keto + - ./config/.generated/ory/keto:/etc/config/keto command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] depends_on: postgres_ory: @@ -125,7 +125,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./docker/ory/keto:/etc/config/keto + - ./config/.generated/ory/keto:/etc/config/keto command: serve -c /etc/config/keto/keto.yml depends_on: keto-migrate: @@ -159,7 +159,7 @@ services: - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - - ./docker/ory/oathkeeper:/etc/config/oathkeeper + - ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper - oathkeeper_logs:/var/log/oathkeeper entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] depends_on: @@ -205,9 +205,27 @@ services: /bin/sh -c " apk add --no-cache curl; echo 'Wait for services...'; - until curl -s http://kratos:4433/health/ready; do sleep 1; done; - until curl -s http://hydra:4444/health/ready; do sleep 1; done; - until curl -s http://keto:4466/health/ready; do sleep 1; done; + check_ready() { + name=\"$$1\"; + url=\"$$2\"; + max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\"; + i=1; + while [ \"$$i\" -le \"$$max\" ]; do + if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then + echo \"Ory service ready: $$name\"; + return 0; + fi; + echo \"Waiting for Ory service: $$name ($$i/$$max)\"; + i=$$((i + 1)); + sleep 1; + done; + echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2; + echo \"ERROR: Check service logs: docker logs ory_$$name\" >&2; + return 1; + }; + check_ready kratos http://kratos:4433/health/ready || exit 1; + check_ready hydra http://hydra:4444/health/ready || exit 1; + check_ready keto http://keto:4466/health/ready || exit 1; echo 'Ory Stack is fully operational!';" depends_on: - kratos diff --git a/deploy/create-instance.sh b/deploy/create-instance.sh index ecbe46e8..e386e774 100644 --- a/deploy/create-instance.sh +++ b/deploy/create-instance.sh @@ -18,11 +18,12 @@ echo "🚀 Creating instance: ${INSTANCE_NAME} (Port Prefix: ${PORT_PREFIX}xxx)" # 1. 폴더 구조 생성 mkdir -p "${TARGET_DIR}/gateway" +mkdir -p "${TARGET_DIR}/config/.generated" mkdir -p "${TARGET_DIR}/ory/init-db" -mkdir -p "${TARGET_DIR}/ory/kratos" -mkdir -p "${TARGET_DIR}/ory/hydra" -mkdir -p "${TARGET_DIR}/ory/keto" -mkdir -p "${TARGET_DIR}/ory/oathkeeper" +mkdir -p "${TARGET_DIR}/ory/templates/kratos" +mkdir -p "${TARGET_DIR}/ory/templates/hydra" +mkdir -p "${TARGET_DIR}/ory/templates/keto" +mkdir -p "${TARGET_DIR}/ory/templates/oathkeeper" mkdir -p "${TARGET_DIR}/userfront" mkdir -p "${TARGET_DIR}/adminfront" mkdir -p "${TARGET_DIR}/devfront" @@ -47,13 +48,15 @@ cp "${BASE_DIR}/templates/docker-compose.yaml" "${TARGET_DIR}/" sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/gateway/nginx.conf" > "${TARGET_DIR}/gateway/nginx.conf" sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/userfront/nginx.conf" > "${TARGET_DIR}/userfront/nginx.conf" -# Oathkeeper Rules -sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/ory/oathkeeper/rules.json" > "${TARGET_DIR}/ory/oathkeeper/rules.json" -cp "${TARGET_DIR}/ory/oathkeeper/rules.json" "${TARGET_DIR}/ory/oathkeeper/rules.active.json" +# Oathkeeper Rules template +sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/ory/oathkeeper/rules.json" > "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" +cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.stage.json" +cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.prod.json" +cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.active.json" -# Kratos Config +# Kratos Config template sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g; s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g" \ - "${BASE_DIR}/templates/ory/kratos/kratos.yml" > "${TARGET_DIR}/ory/kratos/kratos.yml" + "${BASE_DIR}/templates/ory/kratos/kratos.yml.template" > "${TARGET_DIR}/ory/templates/kratos/kratos.yml.template" # Vite Configs sed "s/{{ADMINFRONT_DOMAIN}}/${ADMINFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \ @@ -71,12 +74,18 @@ sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g; s/{{CLIENT_ID}}/devfront/g" \ sed "s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g" \ "${BASE_DIR}/templates/orgfront/auth.ts" > "${TARGET_DIR}/orgfront/auth.ts" -# 5. Ory 정적 설정 복사 +# 5. Ory template 복사 및 완성 config 렌더링 if [ -d "${BASE_DIR}/../docker/ory/init-db" ]; then cp -n "${BASE_DIR}/../docker/ory/init-db/"* "${TARGET_DIR}/ory/init-db/" 2>/dev/null || true; fi -if [ -d "${BASE_DIR}/../docker/ory/kratos" ]; then cp -n "${BASE_DIR}/../docker/ory/kratos/"* "${TARGET_DIR}/ory/kratos/" 2>/dev/null || true; fi -if [ -d "${BASE_DIR}/../docker/ory/hydra" ]; then cp -n "${BASE_DIR}/../docker/ory/hydra/"* "${TARGET_DIR}/ory/hydra/" 2>/dev/null || true; fi -if [ -d "${BASE_DIR}/../docker/ory/keto" ]; then cp -n "${BASE_DIR}/../docker/ory/keto/"* "${TARGET_DIR}/ory/keto/" 2>/dev/null || true; fi -if [ -d "${BASE_DIR}/../docker/ory/oathkeeper" ]; then cp -n "${BASE_DIR}/../docker/ory/oathkeeper/"* "${TARGET_DIR}/ory/oathkeeper/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/kratos" ]; then cp -n "${BASE_DIR}/../docker/ory/kratos/"* "${TARGET_DIR}/ory/templates/kratos/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/kratos/courier-templates" ]; then cp -a "${BASE_DIR}/../docker/ory/kratos/courier-templates" "${TARGET_DIR}/ory/templates/kratos/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/hydra" ]; then cp -n "${BASE_DIR}/../docker/ory/hydra/"* "${TARGET_DIR}/ory/templates/hydra/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/keto" ]; then cp -n "${BASE_DIR}/../docker/ory/keto/"* "${TARGET_DIR}/ory/templates/keto/" 2>/dev/null || true; fi +if [ -d "${BASE_DIR}/../docker/ory/oathkeeper" ]; then cp -n "${BASE_DIR}/../docker/ory/oathkeeper/"* "${TARGET_DIR}/ory/templates/oathkeeper/" 2>/dev/null || true; fi + +ORY_CONFIG_ENV_FILES="${TARGET_DIR}/.env" \ +ORY_CONFIG_TEMPLATE_ROOT="${TARGET_DIR}/ory/templates" \ +ORY_CONFIG_OUTPUT_DIR="${TARGET_DIR}/config/.generated/ory" \ + bash "${BASE_DIR}/../scripts/render_ory_config.sh" # 6. 마무리 chmod +x "${TARGET_DIR}/.env" diff --git a/deploy/templates/docker-compose.yaml b/deploy/templates/docker-compose.yaml index 5544039f..4c0e230d 100644 --- a/deploy/templates/docker-compose.yaml +++ b/deploy/templates/docker-compose.yaml @@ -69,7 +69,7 @@ services: - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - - ./ory/kratos:/etc/config/kratos:ro + - ./config/.generated/ory/kratos:/etc/config/kratos:ro command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes networks: [app_net] depends_on: @@ -94,7 +94,7 @@ services: - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - - ./ory/kratos:/etc/config/kratos:ro + - ./config/.generated/ory/kratos:/etc/config/kratos:ro command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier networks: [app_net] depends_on: @@ -122,7 +122,7 @@ services: - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} volumes: - - ./ory/hydra:/etc/config/hydra:ro + - ./config/.generated/ory/hydra:/etc/config/hydra:ro command: serve -c /etc/config/hydra/hydra.yml all --dev networks: [app_net] depends_on: @@ -134,7 +134,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./ory/keto:/etc/config/keto:ro + - ./config/.generated/ory/keto:/etc/config/keto:ro command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] networks: [app_net] depends_on: @@ -147,7 +147,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./ory/keto:/etc/config/keto:ro + - ./config/.generated/ory/keto:/etc/config/keto:ro command: serve -c /etc/config/keto/keto.yml networks: [app_net] depends_on: @@ -173,7 +173,7 @@ services: - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - - ./ory/oathkeeper:/etc/config/oathkeeper:ro + - ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper:ro - oathkeeper_logs:/var/log/oathkeeper entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: [app_net] @@ -189,9 +189,27 @@ services: /bin/sh -c " apk add --no-cache curl; echo 'Wait for Ory services...'; - until curl -s http://kratos:4433/health/ready; do sleep 1; done; - until curl -s http://hydra:4444/health/ready; do sleep 1; done; - until curl -s http://keto:4466/health/ready; do sleep 1; done; + check_ready() { + name=\"$$1\"; + url=\"$$2\"; + max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\"; + i=1; + while [ \"$$i\" -le \"$$max\" ]; do + if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then + echo \"Ory service ready: $$name\"; + return 0; + fi; + echo \"Waiting for Ory service: $$name ($$i/$$max)\"; + i=$$((i + 1)); + sleep 1; + done; + echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2; + echo \"ERROR: Check service logs: docker logs $${COMPOSE_PROJECT_NAME}_$$name\" >&2; + return 1; + }; + check_ready kratos http://kratos:4433/health/ready || exit 1; + check_ready hydra http://hydra:4444/health/ready || exit 1; + check_ready keto http://keto:4466/health/ready || exit 1; echo 'Ory stack is ready.';" depends_on: - kratos diff --git a/deploy/templates/ory/kratos/kratos.yml b/deploy/templates/ory/kratos/kratos.yml.template similarity index 58% rename from deploy/templates/ory/kratos/kratos.yml rename to deploy/templates/ory/kratos/kratos.yml.template index 45fa5952..de5b981b 100644 --- a/deploy/templates/ory/kratos/kratos.yml +++ b/deploy/templates/ory/kratos/kratos.yml.template @@ -1,20 +1,17 @@ version: v26.2.0 -dsn: ${DSN} +dsn: ${KRATOS_DSN} serve: public: - base_url: ${KRATOS_BROWSER_URL} + base_url: http://localhost:4433/ cors: enabled: true allowed_origins: - http://backend:{{BACKEND_PORT}} - - ${USERFRONT_URL} - - ${ADMINFRONT_URL} - - ${DEVFRONT_URL} - - ${ORGFRONT_URL} + - http://localhost:{{USERFRONT_PORT}} admin: - base_url: ${KRATOS_ADMIN_URL} + base_url: http://localhost:4434/ session: cookie: @@ -23,22 +20,17 @@ session: path: / selfservice: - default_browser_return_url: ${KRATOS_UI_URL} + default_browser_return_url: http://localhost:{{USERFRONT_PORT}}/ allowed_return_urls: - - ${KRATOS_UI_URL} - - ${KRATOS_UI_URL}/ - - ${USERFRONT_URL} - - ${USERFRONT_URL}/ - - ${USERFRONT_URL}/ko - - ${USERFRONT_URL}/ko/ - - ${USERFRONT_URL}/en - - ${USERFRONT_URL}/en/ - - ${USERFRONT_URL}/auth/callback - - ${USERFRONT_URL}/ko/auth/callback - - ${USERFRONT_URL}/en/auth/callback - - ${ADMINFRONT_URL}/auth/callback - - ${DEVFRONT_URL}/auth/callback - - ${ORGFRONT_URL}/auth/callback + - http://localhost:{{USERFRONT_PORT}} + - http://localhost:{{USERFRONT_PORT}}/ + - http://localhost:{{USERFRONT_PORT}}/ko + - http://localhost:{{USERFRONT_PORT}}/ko/ + - http://localhost:{{USERFRONT_PORT}}/en + - http://localhost:{{USERFRONT_PORT}}/en/ + - http://localhost:{{USERFRONT_PORT}}/auth/callback + - http://localhost:{{USERFRONT_PORT}}/ko/auth/callback + - http://localhost:{{USERFRONT_PORT}}/en/auth/callback methods: password: @@ -51,24 +43,24 @@ selfservice: flows: error: - ui_url: ${KRATOS_UI_URL}/error + ui_url: http://localhost:{{USERFRONT_PORT}}/error settings: - ui_url: ${KRATOS_UI_URL}/error?error=settings_disabled + ui_url: http://localhost:{{USERFRONT_PORT}}/error?error=settings_disabled privileged_session_max_age: 15m recovery: - ui_url: ${KRATOS_UI_URL}/recovery + ui_url: http://localhost:{{USERFRONT_PORT}}/recovery use: code verification: - ui_url: ${KRATOS_UI_URL}/verification + ui_url: http://localhost:{{USERFRONT_PORT}}/verification use: code logout: after: - default_browser_return_url: ${KRATOS_UI_URL}/login + default_browser_return_url: http://localhost:{{USERFRONT_PORT}}/login login: - ui_url: ${KRATOS_UI_URL}/login + ui_url: http://localhost:{{USERFRONT_PORT}}/login lifespan: 10m registration: - ui_url: ${KRATOS_UI_URL}/registration + ui_url: http://localhost:{{USERFRONT_PORT}}/registration lifespan: 10m log: diff --git a/docker/compose.ory.yaml b/docker/compose.ory.yaml index 02a455e9..2494ed35 100644 --- a/docker/compose.ory.yaml +++ b/docker/compose.ory.yaml @@ -30,7 +30,7 @@ services: - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${USERFRONT_URL}"]} volumes: - - ./docker/ory/kratos:/etc/config/kratos + - ../config/.generated/ory/kratos:/etc/config/kratos command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes depends_on: postgres_ory: @@ -49,7 +49,7 @@ services: - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${USERFRONT_URL}"]} volumes: - - ./docker/ory/kratos:/etc/config/kratos + - ../config/.generated/ory/kratos:/etc/config/kratos command: serve -c /etc/config/kratos/kratos.yml depends_on: kratos-migrate: @@ -80,7 +80,7 @@ services: - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} volumes: - - ./docker/ory/hydra:/etc/config/hydra + - ../config/.generated/ory/hydra:/etc/config/hydra command: serve -c /etc/config/hydra/hydra.yml all --dev depends_on: hydra-migrate: @@ -94,7 +94,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./docker/ory/keto:/etc/config/keto + - ../config/.generated/ory/keto:/etc/config/keto command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] depends_on: postgres_ory: @@ -108,7 +108,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./docker/ory/keto:/etc/config/keto + - ../config/.generated/ory/keto:/etc/config/keto command: serve -c /etc/config/keto/keto.yml depends_on: keto-migrate: @@ -129,7 +129,7 @@ services: - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - - ./docker/ory/oathkeeper:/etc/config/oathkeeper + - ../config/.generated/ory/oathkeeper:/etc/config/oathkeeper - oathkeeper_logs:/var/log/oathkeeper entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: @@ -152,9 +152,27 @@ services: /bin/sh -c " apk add --no-cache curl; echo 'Wait for services...'; - until curl -s http://kratos:4433/health/ready; do sleep 1; done; - until curl -s http://hydra:4444/health/ready; do sleep 1; done; - until curl -s http://keto:4466/health/ready; do sleep 1; done; + check_ready() { + name=\"$$1\"; + url=\"$$2\"; + max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\"; + i=1; + while [ \"$$i\" -le \"$$max\" ]; do + if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then + echo \"Ory service ready: $$name\"; + return 0; + fi; + echo \"Waiting for Ory service: $$name ($$i/$$max)\"; + i=$$((i + 1)); + sleep 1; + done; + echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2; + echo \"ERROR: Check service logs: docker logs ory_$$name\" >&2; + return 1; + }; + check_ready kratos http://kratos:4433/health/ready || exit 1; + check_ready hydra http://hydra:4444/health/ready || exit 1; + check_ready keto http://keto:4466/health/ready || exit 1; echo 'Ory Stack is fully operational!';" depends_on: - kratos diff --git a/docker/ory/hydra/hydra.yml b/docker/ory/hydra/hydra.yml.template similarity index 97% rename from docker/ory/hydra/hydra.yml rename to docker/ory/hydra/hydra.yml.template index 7bf79b10..811eec27 100644 --- a/docker/ory/hydra/hydra.yml +++ b/docker/ory/hydra/hydra.yml.template @@ -1,4 +1,4 @@ -dsn: ${DSN} +dsn: ${HYDRA_DSN} serve: cookies: @@ -77,7 +77,7 @@ urls: secrets: system: - - ${SECRETS_SYSTEM} + - ${HYDRA_SYSTEM_SECRET} webfinger: oidc_discovery: diff --git a/docker/ory/keto/keto.yml b/docker/ory/keto/keto.yml.template similarity index 91% rename from docker/ory/keto/keto.yml rename to docker/ory/keto/keto.yml.template index 3ec2be81..44fdb408 100644 --- a/docker/ory/keto/keto.yml +++ b/docker/ory/keto/keto.yml.template @@ -1,5 +1,5 @@ version: v0.11.0 -dsn: ${DSN} +dsn: ${KETO_DSN} serve: read: host: 0.0.0.0 diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml.template similarity index 58% rename from docker/ory/kratos/kratos.yml rename to docker/ory/kratos/kratos.yml.template index d1bd22cd..ad713e9a 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml.template @@ -1,21 +1,21 @@ version: v26.2.0 -dsn: ${DSN} +dsn: ${KRATOS_DSN} serve: public: - base_url: ${KRATOS_BROWSER_URL} + base_url: http://localhost:4433/ cors: enabled: true allowed_origins: - - ${USERFRONT_URL} - - ${ADMINFRONT_URL} - - ${DEVFRONT_URL} - - ${ORGFRONT_URL} + - http://localhost:5000 + - http://localhost:5173 + - http://localhost:5174 + - http://localhost:5175 - http://backend:3000 - http://baron_backend:3000 admin: - base_url: ${KRATOS_ADMIN_URL} + base_url: http://localhost:4434/ session: cookie: @@ -24,22 +24,20 @@ session: path: / selfservice: - default_browser_return_url: ${KRATOS_UI_URL} + default_browser_return_url: http://localhost:5000/ allowed_return_urls: - - ${KRATOS_UI_URL} - - ${KRATOS_UI_URL}/ - - ${USERFRONT_URL} - - ${USERFRONT_URL}/ - - ${USERFRONT_URL}/ko - - ${USERFRONT_URL}/ko/ - - ${USERFRONT_URL}/en - - ${USERFRONT_URL}/en/ - - ${USERFRONT_URL}/auth/callback - - ${USERFRONT_URL}/ko/auth/callback - - ${USERFRONT_URL}/en/auth/callback - - ${ADMINFRONT_URL}/auth/callback - - ${DEVFRONT_URL}/auth/callback - - ${ORGFRONT_URL}/auth/callback + - http://localhost:5000 + - http://localhost:5000/ + - http://localhost:5000/ko + - http://localhost:5000/ko/ + - http://localhost:5000/en + - http://localhost:5000/en/ + - http://localhost:5000/auth/callback + - http://localhost:5000/ko/auth/callback + - http://localhost:5000/en/auth/callback + - http://localhost:5173/auth/callback + - http://localhost:5174/auth/callback + - http://localhost:5175/auth/callback methods: password: @@ -52,24 +50,24 @@ selfservice: flows: error: - ui_url: ${KRATOS_UI_URL}/error + ui_url: http://localhost:5000/error settings: - ui_url: ${KRATOS_UI_URL}/error?error=settings_disabled + ui_url: http://localhost:5000/error?error=settings_disabled privileged_session_max_age: 15m recovery: - ui_url: ${KRATOS_UI_URL}/recovery + ui_url: http://localhost:5000/recovery use: code verification: - ui_url: ${KRATOS_UI_URL}/verification + ui_url: http://localhost:5000/verification use: code logout: after: - default_browser_return_url: ${KRATOS_UI_URL}/login + default_browser_return_url: http://localhost:5000/login login: - ui_url: ${KRATOS_UI_URL}/login + ui_url: http://localhost:5000/login lifespan: 10m registration: - ui_url: ${KRATOS_UI_URL}/registration + ui_url: http://localhost:5000/registration lifespan: 10m log: diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml.template similarity index 100% rename from docker/ory/oathkeeper/oathkeeper.yml rename to docker/ory/oathkeeper/oathkeeper.yml.template diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index d4a91f9c..2ed6b07c 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -114,7 +114,7 @@ services: - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - - ./docker/ory/kratos:/etc/config/kratos + - ./config/.generated/ory/kratos:/etc/config/kratos command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes depends_on: postgres_ory: @@ -140,7 +140,7 @@ services: - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login volumes: - - ./docker/ory/kratos:/etc/config/kratos + - ./config/.generated/ory/kratos:/etc/config/kratos command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier depends_on: kratos-migrate: @@ -171,7 +171,7 @@ services: - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} volumes: - - ./docker/ory/hydra:/etc/config/hydra + - ./config/.generated/ory/hydra:/etc/config/hydra command: serve -c /etc/config/hydra/hydra.yml all --dev depends_on: hydra-migrate: @@ -185,7 +185,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./docker/ory/keto:/etc/config/keto + - ./config/.generated/ory/keto:/etc/config/keto command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] depends_on: postgres_ory: @@ -199,7 +199,7 @@ services: environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 volumes: - - ./docker/ory/keto:/etc/config/keto + - ./config/.generated/ory/keto:/etc/config/keto command: serve -c /etc/config/keto/keto.yml depends_on: keto-migrate: @@ -236,7 +236,7 @@ services: - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} volumes: - - ./docker/ory/oathkeeper:/etc/config/oathkeeper + - ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper - oathkeeper_logs:/var/log/oathkeeper entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: @@ -271,9 +271,27 @@ services: /bin/sh -c " apk add --no-cache curl; echo 'Wait for services...'; - until curl -s http://kratos:4433/health/ready; do sleep 1; done; - until curl -s http://hydra:4444/health/ready; do sleep 1; done; - until curl -s http://keto:4466/health/ready; do sleep 1; done; + check_ready() { + name=\"$$1\"; + url=\"$$2\"; + max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\"; + i=1; + while [ \"$$i\" -le \"$$max\" ]; do + if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then + echo \"Ory service ready: $$name\"; + return 0; + fi; + echo \"Waiting for Ory service: $$name ($$i/$$max)\"; + i=$$((i + 1)); + sleep 1; + done; + echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2; + echo \"ERROR: Check service logs: docker logs ory_$$name\" >&2; + return 1; + }; + check_ready kratos http://kratos:4433/health/ready || exit 1; + check_ready hydra http://hydra:4444/health/ready || exit 1; + check_ready keto http://keto:4466/health/ready || exit 1; echo 'Ory Stack is fully operational!';" depends_on: - kratos diff --git a/docs/TEST_GUIDE.md b/docs/TEST_GUIDE.md index c18e57d0..f9af9c0e 100644 --- a/docs/TEST_GUIDE.md +++ b/docs/TEST_GUIDE.md @@ -5,7 +5,7 @@ ## 1. 준비 사항 테스트를 실행하기 위해 다음 도구들이 설치되어 있어야 합니다. - **Docker & Docker Compose** (백엔드 인프라 의존성용) -- **Go 1.25+** +- **Go 1.26.2+** - **Flutter SDK** - **Node.js 24 LTS+** diff --git a/docs/개발완료보고서.md b/docs/개발완료보고서.md index 5ec115a7..09087f4d 100644 --- a/docs/개발완료보고서.md +++ b/docs/개발완료보고서.md @@ -88,7 +88,7 @@ flowchart ``` ### 1. Backend (Go Fiber) -- **Language**: Go 1.25+ +- **Language**: Go 1.26.2+ - **Framework**: Fiber v2.25+ - **Database**: - **ClickHouse**: 감사 로그 (고성능 데이터 수집) diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh index 544638ac..dfe3f60e 100755 --- a/scripts/auth_config.sh +++ b/scripts/auth_config.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OUTPUT_DIR="$ROOT_DIR/.generated" +OUTPUT_DIR="$ROOT_DIR/config/.generated" OUTPUT_FILE="$OUTPUT_DIR/auth-config.env" MODE="${1:-build}" diff --git a/scripts/render_ory_config.sh b/scripts/render_ory_config.sh new file mode 100755 index 00000000..d2487101 --- /dev/null +++ b/scripts/render_ory_config.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT_DIR="${ORY_CONFIG_OUTPUT_DIR:-$ROOT_DIR/config/.generated/ory}" +TEMPLATE_ROOT="${ORY_CONFIG_TEMPLATE_ROOT:-$ROOT_DIR/docker/ory}" + +load_env_file() { + local env_file="$1" + if [[ -f "$env_file" ]]; then + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a + fi +} + +fail() { + echo "[ory-config] ERROR: $1" >&2 + exit 1 +} + +render_template() { + local src="$1" + local dst="$2" + mkdir -p "$(dirname "$dst")" + perl -pe ' + s/\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/ + exists $ENV{$1} ? $ENV{$1} : defined $3 ? $3 : die "missing env var: $1\n" + /gex + ' "$src" > "$dst" +} + +copy_if_exists() { + local src="$1" + local dst="$2" + if [[ -e "$src" ]]; then + mkdir -p "$(dirname "$dst")" + cp -a "$src" "$dst" + fi +} + +if [[ -n "${ORY_CONFIG_ENV_FILES:-}" ]]; then + IFS=':' read -r -a env_files <<<"$ORY_CONFIG_ENV_FILES" + for env_file in "${env_files[@]}"; do + load_env_file "$env_file" + done +else + load_env_file "$ROOT_DIR/.env" + load_env_file "$ROOT_DIR/config/.generated/auth-config.env" +fi + +ORY_POSTGRES_USER="${ORY_POSTGRES_USER:-ory}" +ORY_POSTGRES_PASSWORD="${ORY_POSTGRES_PASSWORD:-secret}" +KRATOS_DB="${KRATOS_DB:-ory_kratos}" +HYDRA_DB="${HYDRA_DB:-ory_hydra}" +KETO_DB="${KETO_DB:-ory_keto}" +KRATOS_DSN="${KRATOS_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20}" +HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}" +KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}" +HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}" +OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" +OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" + +export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET +export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +render_template "$TEMPLATE_ROOT/kratos/kratos.yml.template" "$OUTPUT_DIR/kratos/kratos.yml" +copy_if_exists "$TEMPLATE_ROOT/kratos/identity.schema.json" "$OUTPUT_DIR/kratos/identity.schema.json" +copy_if_exists "$TEMPLATE_ROOT/kratos/courier-http.jsonnet" "$OUTPUT_DIR/kratos/courier-http.jsonnet" +if [[ -d "$TEMPLATE_ROOT/kratos/courier-templates" ]]; then + mkdir -p "$OUTPUT_DIR/kratos" + cp -a "$TEMPLATE_ROOT/kratos/courier-templates" "$OUTPUT_DIR/kratos/courier-templates" +fi + +render_template "$TEMPLATE_ROOT/hydra/hydra.yml.template" "$OUTPUT_DIR/hydra/hydra.yml" + +render_template "$TEMPLATE_ROOT/keto/keto.yml.template" "$OUTPUT_DIR/keto/keto.yml" +copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.ts" "$OUTPUT_DIR/keto/namespaces.ts" +copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.yml" "$OUTPUT_DIR/keto/namespaces.yml" + +render_template "$TEMPLATE_ROOT/oathkeeper/oathkeeper.yml.template" "$OUTPUT_DIR/oathkeeper/oathkeeper.yml" +copy_if_exists "$TEMPLATE_ROOT/oathkeeper/entrypoint.sh" "$OUTPUT_DIR/oathkeeper/entrypoint.sh" +chmod +x "$OUTPUT_DIR/oathkeeper/entrypoint.sh" +for rules_file in "$TEMPLATE_ROOT"/oathkeeper/rules*.json; do + [[ -e "$rules_file" ]] || continue + copy_if_exists "$rules_file" "$OUTPUT_DIR/oathkeeper/$(basename "$rules_file")" +done + +if find "$OUTPUT_DIR" -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.json' -o -name '*.toml' \) -print0 | xargs -0 grep -n '\${' >/tmp/ory-render-unresolved.$$ 2>/dev/null; then + cat /tmp/ory-render-unresolved.$$ >&2 + rm -f /tmp/ory-render-unresolved.$$ + fail "rendered Ory config contains unresolved placeholders" +fi +rm -f /tmp/ory-render-unresolved.$$ + +echo "[ory-config] wrote: $OUTPUT_DIR" diff --git a/test/backend_go_version_policy_test.sh b/test/backend_go_version_policy_test.sh new file mode 100644 index 00000000..64072168 --- /dev/null +++ b/test/backend_go_version_policy_test.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET_GO_VERSION="1.26.2" + +GO_MOD="$ROOT_DIR/backend/go.mod" +BACKEND_DOCKERFILE="$ROOT_DIR/backend/Dockerfile" +LOCAL_COMPOSE="$ROOT_DIR/docker-compose.yaml" +STAGING_COMPOSE="$ROOT_DIR/docker/docker-compose.staging.template.yaml" +PULL_COMPOSE="$ROOT_DIR/docker/staging_pull_compose.template.yaml" +DEPLOY_TEMPLATE="$ROOT_DIR/deploy/templates/docker-compose.yaml" +README="$ROOT_DIR/README.md" +README_EN="$ROOT_DIR/README_en.md" +TEST_GUIDE="$ROOT_DIR/docs/TEST_GUIDE.md" +COMPLETION_REPORT="$ROOT_DIR/docs/개발완료보고서.md" + +for file in \ + "$GO_MOD" \ + "$BACKEND_DOCKERFILE" \ + "$LOCAL_COMPOSE" \ + "$STAGING_COMPOSE" \ + "$PULL_COMPOSE" \ + "$DEPLOY_TEMPLATE" \ + "$README" \ + "$README_EN" \ + "$TEST_GUIDE" \ + "$COMPLETION_REPORT" +do + if [[ ! -f "$file" ]]; then + echo "ERROR: expected file not found: $file" >&2 + exit 1 + fi +done + +if ! grep -Eq "^go ${TARGET_GO_VERSION}$" "$GO_MOD"; then + echo "ERROR: backend go.mod must use go ${TARGET_GO_VERSION}." >&2 + exit 1 +fi + +if ! grep -Eq "^FROM golang:${TARGET_GO_VERSION}-alpine$" "$BACKEND_DOCKERFILE"; then + echo "ERROR: backend Dockerfile must use golang:${TARGET_GO_VERSION}-alpine." >&2 + exit 1 +fi + +for file in "$LOCAL_COMPOSE" "$PULL_COMPOSE"; do + if ! grep -Fq "context: ./backend" "$file" && ! grep -Fq "context: ../../backend" "$file"; then + echo "ERROR: backend compose build context is missing in $file." >&2 + exit 1 + fi +done + +for file in "$STAGING_COMPOSE" "$DEPLOY_TEMPLATE"; do + if ! grep -Eq "^[[:space:]]+backend:$" "$file"; then + echo "ERROR: backend service is missing in $file." >&2 + exit 1 + fi +done + +legacy_refs="$( + grep -R -nE "golang:1\\.25|^go 1\\.25" \ + "$ROOT_DIR/backend" \ + "$ROOT_DIR/docker-compose.yaml" \ + "$ROOT_DIR/docker" \ + "$ROOT_DIR/deploy/templates" \ + "$README" \ + "$README_EN" \ + "$TEST_GUIDE" \ + "$COMPLETION_REPORT" || true +)" + +if [[ -n "$legacy_refs" ]]; then + echo "ERROR: legacy backend Go version references remain." >&2 + echo "$legacy_refs" >&2 + exit 1 +fi + +echo "OK: backend Go base version policy is ${TARGET_GO_VERSION}" diff --git a/test/make_dev_targets_test.sh b/test/make_dev_targets_test.sh index 367574ef..4d9453f8 100644 --- a/test/make_dev_targets_test.sh +++ b/test/make_dev_targets_test.sh @@ -30,6 +30,11 @@ if ! grep -q "Ensuring Ory stack" <<<"$dry_run_dev"; then exit 1 fi +if ! grep -q "Rendering Ory config" <<<"$dry_run_dev"; then + echo "make dev must render Ory config before starting services." >&2 + exit 1 +fi + app_up_line="$( grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront" <<<"$dry_run_dev" | tail -1 )" @@ -44,6 +49,11 @@ if grep -q -- " -d" <<<"$app_up_line"; then exit 1 fi +if ! grep -q -- " --build" <<<"$app_up_line"; then + echo "make dev must rebuild app service images before starting development containers." >&2 + exit 1 +fi + dry_run_up_dev="$( make --dry-run --always-make -C "$repo_root" up-dev 2>&1 )" @@ -67,6 +77,20 @@ if ! grep -q "Starting App stack (backend/userfront/adminfront/devfront/orgfront exit 1 fi +if ! grep -q "Rendering Ory config" <<<"$dry_run_up_app"; then + echo "make up-app must render Ory config before starting services." >&2 + exit 1 +fi + +up_app_line="$( + grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront.*devfront.*orgfront.*userfront|docker compose .* -f docker-compose.yaml up " <<<"$dry_run_up_app" | tail -1 +)" + +if ! grep -q -- " --build" <<<"$up_app_line"; then + echo "make up-app must rebuild app service images before starting containers." >&2 + exit 1 +fi + dry_run_up_all="$( make --dry-run --always-make -C "$repo_root" up-all 2>&1 )" @@ -84,6 +108,16 @@ if ! grep -q "Starting ALL stacks (infra + ory + app)" <<<"$dry_run_up"; then exit 1 fi +if ! grep -q "config/.generated/auth-config.env" <<<"$dry_run_up"; then + echo "make up must use generated env from config/.generated." >&2 + exit 1 +fi + +if ! grep -q "Rendering Ory config" <<<"$dry_run_up"; then + echo "make up must render Ory config before compose up." >&2 + exit 1 +fi + if ! grep -q "Ensuring Docker networks" <<<"$dry_run_up_all"; then echo "make up-all must ensure external Docker networks before compose up." >&2 exit 1 diff --git a/test/ory_log_pipeline_policy_test.sh b/test/ory_log_pipeline_policy_test.sh index 18c2b5ed..01f30881 100755 --- a/test/ory_log_pipeline_policy_test.sh +++ b/test/ory_log_pipeline_policy_test.sh @@ -3,6 +3,8 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$repo_root/scripts/render_ory_config.sh" >/dev/null + docker run --rm \ -e ORY_CLICKHOUSE_USER=ory \ -e ORY_CLICKHOUSE_PASSWORD=orypass \ @@ -14,12 +16,12 @@ if grep -q '/etc/config/oathkeeper/rules.active.json' "$repo_root/docker/ory/oat exit 1 fi -if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/docker/ory/oathkeeper/oathkeeper.yml"; then +if ! grep -q 'file:///tmp/oathkeeper/rules.active.json' "$repo_root/config/.generated/ory/oathkeeper/oathkeeper.yml"; then echo "ERROR: Oathkeeper config must load active rules from writable runtime storage." >&2 exit 1 fi -if ! grep -q '^version: v26.2.0$' "$repo_root/docker/ory/kratos/kratos.yml"; then +if ! grep -q '^version: v26.2.0$' "$repo_root/config/.generated/ory/kratos/kratos.yml"; then echo "ERROR: Kratos config version must match the v26.2.0 runtime." >&2 exit 1 fi diff --git a/test/ory_v26_compose_policy_test.sh b/test/ory_v26_compose_policy_test.sh index df6be834..ddf467ca 100644 --- a/test/ory_v26_compose_policy_test.sh +++ b/test/ory_v26_compose_policy_test.sh @@ -67,6 +67,30 @@ for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory fi done +for stack_check_file in \ + "$repo_root/compose.ory.yaml" \ + "$repo_root/docker/compose.ory.yaml" \ + "$repo_root/docker/staging_pull_compose.template.yaml" \ + "$repo_root/deploy/templates/docker-compose.yaml" +do + if grep -q 'until curl -s http://' "$stack_check_file"; then + echo "ERROR: Ory stack check must not wait forever; use bounded readiness checks in $stack_check_file." >&2 + exit 1 + fi + if ! grep -q 'ORY_STACK_CHECK_MAX_ATTEMPTS' "$stack_check_file"; then + echo "ERROR: Ory stack check must expose ORY_STACK_CHECK_MAX_ATTEMPTS in $stack_check_file." >&2 + exit 1 + fi + if ! grep -q 'ERROR: Ory service not ready' "$stack_check_file"; then + echo "ERROR: Ory stack check must report the failed service name in $stack_check_file." >&2 + exit 1 + fi + if ! grep -q 'check_ready kratos .* || exit 1' "$stack_check_file"; then + echo "ERROR: Ory stack check must raise a non-zero exit when Kratos is not ready in $stack_check_file." >&2 + exit 1 + fi +done + for expected_url in \ "https://compose-policy.example.test/sso/oidc" \ "https://compose-policy.example.test/sso/login" \ @@ -189,17 +213,17 @@ done deploy_template="$repo_root/deploy/templates/docker-compose.yaml" deploy_env_template="$repo_root/deploy/templates/.env.template" deploy_gateway_template="$repo_root/deploy/templates/gateway/nginx.conf" -deploy_kratos_template="$repo_root/deploy/templates/ory/kratos/kratos.yml" +deploy_kratos_template="$repo_root/deploy/templates/ory/kratos/kratos.yml.template" deploy_oathkeeper_rules_template="$repo_root/deploy/templates/ory/oathkeeper/rules.json" for required_template in \ "$repo_root/deploy/templates/orgfront/vite.config.ts" \ "$repo_root/deploy/templates/orgfront/auth.ts" \ "$repo_root/docker/ory/init-db/01_create_dbs.sh" \ - "$repo_root/docker/ory/hydra/hydra.yml" \ - "$repo_root/docker/ory/keto/keto.yml" \ + "$repo_root/docker/ory/hydra/hydra.yml.template" \ + "$repo_root/docker/ory/keto/keto.yml.template" \ "$repo_root/docker/ory/oathkeeper/entrypoint.sh" \ - "$repo_root/docker/ory/oathkeeper/oathkeeper.yml" + "$repo_root/docker/ory/oathkeeper/oathkeeper.yml.template" do if [[ ! -f "$required_template" ]]; then echo "ERROR: deploy instance generation requires missing source file: $required_template" >&2 @@ -214,8 +238,8 @@ fi for prod_sensitive_file in \ "$repo_root/docker/ory/oathkeeper/rules.prod.json" \ - "$repo_root/docker/ory/kratos/kratos.yml" \ - "$repo_root/deploy/templates/ory/kratos/kratos.yml" + "$repo_root/docker/ory/kratos/kratos.yml.template" \ + "$repo_root/deploy/templates/ory/kratos/kratos.yml.template" do if grep -q "app\\.brsw\\.kr" "$prod_sensitive_file"; then echo "ERROR: Ory production-sensitive config must not hard-code app.brsw.kr: $prod_sensitive_file" >&2 @@ -223,6 +247,51 @@ do fi done +for compose_file in "$repo_root/compose.ory.yaml" "$repo_root/docker/compose.ory.yaml" "$repo_root/docker/staging_pull_compose.template.yaml"; do + if grep -Eq './docker/ory/(kratos|hydra|keto|oathkeeper):/etc/config/' "$compose_file"; then + echo "ERROR: Ory compose must mount rendered config/.generated/ory config, not source templates: $compose_file" >&2 + exit 1 + fi +done + +if grep -Eq '\./ory/(kratos|hydra|keto|oathkeeper):/etc/config/' "$deploy_template"; then + echo "ERROR: deploy template must mount rendered config/.generated/ory config, not source templates." >&2 + exit 1 +fi + +if grep -q 'ory/generated' "$deploy_template" "$repo_root/deploy/create-instance.sh"; then + echo "ERROR: deploy template must use config/.generated/ory, not ory/generated." >&2 + exit 1 +fi + +if ! grep -q '^render-ory-config:' "$repo_root/Makefile"; then + echo "ERROR: Makefile must render Ory config before starting Ory services." >&2 + exit 1 +fi + +if ! grep -q 'scripts/render_ory_config.sh' "$repo_root/.gitea/workflows/staging_code_pull.yml"; then + echo "ERROR: staging code pull must render Ory config before docker compose up." >&2 + exit 1 +fi + +"$repo_root/scripts/render_ory_config.sh" >/dev/null + +for generated_config in \ + "$repo_root/config/.generated/ory/kratos/kratos.yml" \ + "$repo_root/config/.generated/ory/hydra/hydra.yml" \ + "$repo_root/config/.generated/ory/keto/keto.yml" \ + "$repo_root/config/.generated/ory/oathkeeper/oathkeeper.yml" +do + if [[ ! -f "$generated_config" ]]; then + echo "ERROR: Ory rendered config is missing: $generated_config" >&2 + exit 1 + fi + if grep -q '\${' "$generated_config"; then + echo "ERROR: Ory rendered config must not contain placeholders: $generated_config" >&2 + exit 1 + fi +done + for service in kratos-migrate kratos hydra-migrate hydra keto-migrate keto oathkeeper_logs_init oathkeeper; do if ! grep -q "^ $service:" "$deploy_template"; then echo "ERROR: deploy template Ory stack must include service: $service" >&2 From 3e8adbfbfdba789ada423e49e693b84ec2e98c4a Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 14:09:21 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=EB=B0=B1=EC=B1=84=EB=84=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=95=84=EC=9B=83=20URI=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 26 ++++++++++++++++--- .../features/clients/ClientGeneralPage.tsx | 25 +++++++++++++++--- devfront/src/locales/en.toml | 2 +- devfront/src/locales/ko.toml | 2 +- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 0b32a39a..e64e2e61 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "net/url" "os" @@ -2749,16 +2750,35 @@ func validateBackchannelLogoutURI(raw string) error { case "https": return nil case "http": - host := strings.ToLower(parsed.Hostname()) - if host == "localhost" || host == "127.0.0.1" { + if isAllowedLocalBackchannelLogoutHost(parsed.Hostname()) { return nil } - return fmt.Errorf("backchannelLogoutUri must use https outside localhost development") + return fmt.Errorf("backchannelLogoutUri must use https outside local development") default: return fmt.Errorf("backchannelLogoutUri must use http or https") } } +func isAllowedLocalBackchannelLogoutHost(rawHost string) bool { + host := strings.ToLower(strings.TrimSpace(rawHost)) + if host == "" { + return false + } + + switch host { + case "localhost", "127.0.0.1", "::1", "host.docker.internal": + return true + } + + if ip := net.ParseIP(host); ip != nil { + return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() + } + + // Docker service names and other single-label local hostnames are + // permitted only for local HTTP development workflows. + return !strings.Contains(host, ".") +} + func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { if metadata == nil { return metadata, nil diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index b189abdd..8e3ace74 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -288,7 +288,26 @@ function isValidBackchannelLogoutUrl(value: string): boolean { if (url.protocol !== "http:") { return false; } - return url.hostname === "localhost" || url.hostname === "127.0.0.1"; + const host = url.hostname.toLowerCase(); + if ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "host.docker.internal" + ) { + return true; + } + if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return ( + host.startsWith("10.") || + host.startsWith("192.168.") || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(host) || + host.startsWith("169.254.") + ); + } + // Docker service names and other single-label local hosts are allowed + // only for HTTP local development use. + return !host.includes("."); } catch { return false; } @@ -949,7 +968,7 @@ function ClientGeneralPage() { throw new Error( t( "msg.dev.clients.general.backchannel_logout.invalid", - "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.", + "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.", ), ); } @@ -1590,7 +1609,7 @@ function ClientGeneralPage() {

{t( "msg.dev.clients.general.backchannel_logout.invalid", - "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다.", + "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다.", )}

) : null} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index a4d69010..f7daf9d7 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -435,7 +435,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after [msg.dev.clients.general.backchannel_logout] uri_help = "RP endpoint that receives Baron's session termination event via server-to-server POST." -invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1." +invalid = "The Back-Channel Logout URI format is invalid. Production requires https, and local development only allows http on localhost/127.0.0.1, host.docker.internal, Docker service names, and private IPs." session_required_help = "Use this when the RP should process logout_token only if the sid claim is included." session_required_on = "On: process logout only when the logout_token contains a sid." session_required_off = "Off: process logout using sub even if sid is missing." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index eecb25aa..678f9a7d 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -435,7 +435,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 [msg.dev.clients.general.backchannel_logout] uri_help = "Baron이 세션 종료 이벤트를 서버 간 POST로 전달할 RP endpoint입니다." -invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https, 로컬 개발 환경은 localhost/127.0.0.1의 http만 허용됩니다." +invalid = "Back-Channel Logout URI 형식이 올바르지 않습니다. 운영 환경은 https를 사용하고, 로컬 개발 환경은 localhost/127.0.0.1, host.docker.internal, Docker 서비스명, 사설 IP의 http만 허용됩니다." session_required_help = "RP가 logout_token에 sid claim이 포함된 경우에만 처리하도록 요구할 때 사용합니다." session_required_on = "켜면: logout_token 안에 sid가 있을 때만 로그아웃 처리" session_required_off = "끄면: sid가 없어도 sub만으로 로그아웃 처리 가능" From 53cad429a16795d8da78f2d1865f5e81c3c2985e Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 14:09:44 +0900 Subject: [PATCH 06/15] =?UTF-8?q?PCKE=20=EA=B5=AC=ED=98=84=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/pkce-backchannel-logout-guide.md | 321 ++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/pkce-backchannel-logout-guide.md diff --git a/docs/pkce-backchannel-logout-guide.md b/docs/pkce-backchannel-logout-guide.md new file mode 100644 index 00000000..6385ab7a --- /dev/null +++ b/docs/pkce-backchannel-logout-guide.md @@ -0,0 +1,321 @@ +# PKCE RP Back-Channel Logout 구현 가이드 + +이 문서는 Baron SSO와 연동하는 PKCE RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다. + +## 목적 + +PKCE RP도 OIDC `Authorization Code + PKCE` 흐름을 사용하면서 Baron SSO의 원격 세션 종료 이벤트를 받을 수 있어야 합니다. 다만 `Back-Channel Logout`은 브라우저가 아니라 OP(Baron)가 RP 서버로 직접 `logout_token`을 보내는 방식이므로, **순수 frontend-only PKCE 앱만으로는 구현할 수 없습니다.** + +즉, PKCE RP가 `Back-Channel Logout`을 사용하려면 다음 둘을 모두 가져야 합니다. + +1. PKCE 로그인 플로우를 시작하고 callback을 처리하는 RP +2. `logout_token`을 수신하는 서버 endpoint + +## 적용 대상 + +이 가이드는 다음 경우를 대상으로 합니다. + +- 브라우저에서 `Authorization Code + PKCE`를 사용하는 RP +- RP가 자체 세션 또는 BFF 세션을 보유하는 경우 +- RP가 `Back-Channel Logout URI`를 등록하고 Baron의 세션 종료 이벤트를 직접 수신하려는 경우 + +다음 경우는 이 가이드의 직접 대상이 아닙니다. + +- 순수 frontend-only SPA +- 서버 없이 `localStorage`/`sessionStorage`만 사용하는 PKCE 앱 + +이 경우에는 `Back-Channel Logout` 대신 front-channel logout, 세션 재검증, 짧은 token TTL 같은 별도 전략을 사용해야 합니다. + +## devfront 등록 기준 + +PKCE RP는 devfront에서 아래 항목을 등록합니다. + +1. `Type`: `pkce` +2. `Redirect URI`: RP callback URL +3. `Back-Channel Logout URI`: RP 서버 endpoint +4. 필요 시 `SID Claim Required` + +예시: + +```text +Type: pkce +Redirect URI: https://rp.example.com/callback +Back-Channel Logout URI: https://rp.example.com/backchannel-logout +SID Claim Required: off +``` + +로컬 Docker 개발 예시: + +```text +Redirect URI: http://localhost:3333/callback +Back-Channel Logout URI: http://baron-sso-login-demo:3333/backchannel-logout +``` + +주의: + +- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다. +- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, Docker 서비스명이나 사설 IP를 사용해야 할 수 있습니다. + +## 구현 요구사항 + +PKCE RP는 최소한 아래를 구현해야 합니다. + +### 1. 로그인 후 세션 매핑 저장 + +RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다. + +- `sid -> rpSessionId` +- `sub -> rpSessionId` + +권장 순서는 다음과 같습니다. + +1. `sid`를 우선 저장 +2. `sub`도 함께 저장 +3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정 + +예시: + +```text +sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a +sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30 +rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj +``` + +### 2. `POST /backchannel-logout` endpoint + +RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다. + +예: + +```text +POST /backchannel-logout +Content-Type: application/x-www-form-urlencoded +Body: logout_token= +``` + +RP는 이 endpoint에서: + +1. `logout_token` 존재 여부 확인 +2. JWT 서명 및 claim 검증 +3. `sid` 또는 `sub`로 로컬 세션 탐색 +4. 세션 스토어에서 직접 세션 파기 +5. 성공 시 `2xx` 응답 + +을 수행해야 합니다. + +### 3. `logout_token` 검증 + +RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다. + +현재 Baron의 JWKS endpoint 예시는 다음과 같습니다. + +```text +GET /api/v1/auth/backchannel/jwks.json +``` + +검증 필수 항목: + +1. JWT 서명 검증 +2. `iss`가 Baron OIDC issuer와 일치 +3. `aud`에 현재 RP `client_id` 포함 +4. `iat` 존재 +5. `jti` 존재 +6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함 +7. `nonce`가 없어야 함 +8. `sid` 또는 `sub`가 있어야 함 + +추가 권장 항목: + +- `jti` replay 방지 캐시 +- 시계 오차 허용 범위 설정 +- 검증 실패 시 `400` + +## 세션 종료 기준 + +### 권장 순서 + +1. `sid`로 매칭 시도 +2. 매칭 실패 시 `sub`로 fallback + +이 기준은 `SID Claim Required` 정책에 따라 달라집니다. + +### `SID Claim Required = true` + +- `logout_token`에 `sid`가 있어야만 처리 +- `sub` fallback 금지 +- 세션 모델이 `sid` 중심으로 안정적으로 유지되는 RP에 적합 + +### `SID Claim Required = false` + +- `sid`가 있으면 우선 사용 +- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능 +- 실제 운영에서는 이 모드가 더 현실적일 수 있음 + +## 세션 파기 방식 + +`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다. +반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다. + +예: + +```text +store.destroy(rpSessionId) +``` + +필수 조건: + +- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함 +- 이미 삭제된 세션은 idempotent success 처리 + +## 권장 로그 항목 + +RP는 아래 정도의 로그를 남기는 것을 권장합니다. + +1. 요청 수신 +2. 토큰 검증 성공/실패 +3. `sid`, `sub`, `jti` +4. 매칭된 `rpSessionId` 목록 +5. 세션 파기 성공/실패 수 + +예시: + +```text +[백채널 로그아웃] 요청 수신 +[백채널 로그아웃] 토큰 검증 성공 +[백채널 로그아웃] 세션 탐색 결과 +[백채널 로그아웃] 세션 파기 완료 +[백채널 로그아웃] 처리 완료 +``` + +주의: + +- raw `logout_token` 전체를 로그에 남기지 않습니다. +- access token, refresh token, cookie raw value도 남기지 않습니다. + +## 테스트 체크리스트 + +### 기본 성공 시나리오 + +1. PKCE RP 로그인 +2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인 +3. UserFront에서 `세션 종료` +4. Baron이 RP의 `Back-Channel Logout URI`로 POST +5. RP가 `logout_token` 검증 성공 +6. RP 세션 파기 성공 +7. 보호 페이지 접근 시 비로그인 상태 확인 + +### 확인 포인트 + +1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가 +2. Baron backend가 해당 URI에 실제로 도달 가능한가 +3. RP 로그에 `요청 수신`과 `토큰 검증 성공`이 찍히는가 +4. 세션 스토어에서 실제 세션이 삭제됐는가 +5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가 + +## 구현 예시 구조 + +Node.js/Express 기준 최소 구조 예시는 다음과 같습니다. + +```text +GET /login +GET /callback +GET /profile +GET /logout +POST /backchannel-logout +``` + +내부 저장 예시: + +```text +sidToSessionIds: Map> +subToSessionIds: Map> +sessionIdToBinding: Map +``` + +실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다. + +- 백채널 로그아웃 모듈: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/backchannel-logout.js` +- 데모 앱 엔트리포인트: `https://gitea.hmac.kr/kyy/pkce-login-demo/src/branch/main/app.js` + +이 데모는: + +1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록 +2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결 +3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출 + +구조로 동작합니다. + +## 자주 생기는 문제 + +### 1. `localhost`로는 안 되는데 입력은 저장됨 + +입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다. + +예: + +```text +http://localhost:3333/backchannel-logout +``` + +이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다. + +### 2. `sid`가 로그인 시 값과 다름 + +실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다. + +따라서: + +1. `sid` 우선 +2. `sub` fallback + +구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다. + +### 3. 순수 frontend-only PKCE인데 endpoint를 만들 수 없음 + +그 경우는 `Back-Channel Logout` 자체를 구현할 수 없습니다. 최소한 logout 수신용 서버 컴포넌트를 추가해야 합니다. + +## 로직 흐름 + +```mermaid +sequenceDiagram + autonumber + participant Browser as 브라우저 + participant RP as PKCE RP + participant Baron as Baron SSO + participant Store as 세션 스토어 + + Browser->>RP: GET /login 호출 + RP->>Browser: Baron authorize endpoint로 리다이렉트 + Browser->>Baron: Authorization Code + PKCE 로그인 + Baron->>Browser: /callback?code=... 으로 리다이렉트 + Browser->>RP: GET /callback 호출 + RP->>Baron: code_verifier 포함 token 요청 + Baron-->>RP: ID Token / Access Token 반환 + RP->>Store: RP 세션 생성 + RP->>RP: registerSessionBinding(sessionId, sid, sub) + RP-->>Browser: 로그인 완료 응답 + + Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료 + Baron->>RP: POST /backchannel-logout (logout_token) + RP->>Baron: Back-Channel JWKS로 logout_token 검증 + Baron-->>RP: 서명 / issuer / audience 검증 기준 제공 + RP->>RP: sid 또는 sub로 sessionId 탐색 + RP->>Store: destroy(sessionId) + RP->>RP: removeSessionBinding(sessionId) + RP-->>Baron: 200 OK + + Browser->>RP: GET /profile 호출 + RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답 +``` + +## 권장 결론 + +PKCE RP에서 `Back-Channel Logout`을 쓰려면, 다음 원칙을 따르십시오. + +1. PKCE 로그인 플로우는 그대로 유지 +2. logout 수신용 서버 endpoint 별도 구현 +3. `sid`와 `sub`를 모두 저장 +4. 세션 스토어에서 직접 세션 파기 +5. 로컬 개발 시 Baron backend가 도달 가능한 URI를 사용 + +이 다섯 가지가 갖춰져야 Baron의 원격 세션 종료가 RP 로컬 세션 종료까지 이어집니다. From cbaa208f794091351800b4a1a34d71e5554159da Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 15:58:24 +0900 Subject: [PATCH 07/15] =?UTF-8?q?server=20side=20app=20=EB=B0=B1=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EA=B0=80=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erver-side-app-backchannel-logout-guide.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/server-side-app-backchannel-logout-guide.md diff --git a/docs/server-side-app-backchannel-logout-guide.md b/docs/server-side-app-backchannel-logout-guide.md new file mode 100644 index 00000000..93f18761 --- /dev/null +++ b/docs/server-side-app-backchannel-logout-guide.md @@ -0,0 +1,322 @@ +# Server-Side App RP Back-Channel Logout 구현 가이드 + +이 문서는 Baron SSO와 연동하는 `server-side-app` RP가 `Back-Channel Logout`을 지원하려고 할 때 필요한 구현 기준을 정리합니다. + +## 목적 + +`server-side-app` RP는 confidential client로 동작하면서, Baron SSO의 원격 세션 종료 이벤트를 받아 RP 로컬 세션을 즉시 정리할 수 있어야 합니다. + +즉, `server-side-app` RP는 다음 둘을 모두 구현해야 합니다. + +1. OIDC Authorization Code 로그인과 callback 처리 +2. `logout_token`을 수신하는 `Back-Channel Logout URI` + +## 적용 대상 + +이 가이드는 다음 경우를 대상으로 합니다. + +- `server-side-app` 타입 RP +- confidential client +- `client_secret_basic` 또는 `client_secret_post`를 사용하는 RP +- 자체 서버 세션 또는 BFF 세션을 보유하는 RP + +다음 경우는 이 가이드의 직접 대상이 아닙니다. + +- 순수 frontend-only SPA +- public client 기반 PKCE 앱 + +## devfront 등록 기준 + +`server-side-app` RP는 devfront에서 아래 항목을 등록합니다. + +1. `Type`: `server-side-app` +2. `Redirect URI`: RP callback URL +3. `Back-Channel Logout URI`: RP 서버 endpoint +4. 필요 시 `SID Claim Required` + +예시: + +```text +Type: server-side-app +Redirect URI: http://localhost:4444/callback +Back-Channel Logout URI: http://172.16.9.208:4444/backchannel-logout +SID Claim Required: off +``` + +주의: +- `Back-Channel Logout URI`는 **브라우저 기준 주소가 아니라 Baron backend가 실제로 접근 가능한 주소**여야 합니다. +- Docker 환경에서는 `localhost`가 backend 컨테이너 자신을 가리킬 수 있으므로, 필요하면 사설 IP 또는 Docker 서비스명을 사용해야 합니다. + +## 구현 요구사항 + +`server-side-app` RP는 최소한 아래를 구현해야 합니다. + +### 1. confidential client 구성 + +RP는 일반적으로 아래 중 하나의 인증 방식을 사용합니다. + +1. `client_secret_basic` +2. `client_secret_post` + +즉 token 교환 시: + +- `client_id` +- `client_secret` + +가 함께 사용됩니다. + +PKCE와 달리 `code_verifier`, `code_challenge`는 필수가 아닙니다. + +### 2. 로그인 후 세션 매핑 저장 + +RP는 callback 이후 아래 정보 중 하나 이상을 로컬 세션과 연결해야 합니다. + +- `sid -> rpSessionId` +- `sub -> rpSessionId` + +권장 순서는 다음과 같습니다. + +1. `sid`를 우선 저장 +2. `sub`도 함께 저장 +3. 한 사용자가 여러 브라우저 세션을 가질 수 있으므로 `1:N` 구조를 가정 + +예시: + +```text +sid: 796f5cf7-37e7-494b-9b4c-26cc0c217a6a +sub: 8150cb83-a905-4b50-bdcf-d22046ecdc30 +rpSessionId: DqKlQ8MbsGnn_jfOus1k03MFRDpuXCrj +``` + +### 3. `POST /backchannel-logout` endpoint + +RP는 Baron이 서버 간으로 호출할 endpoint를 제공해야 합니다. + +예: + +```text +POST /backchannel-logout +Content-Type: application/x-www-form-urlencoded +Body: logout_token= +``` + +RP는 이 endpoint에서: + +1. `logout_token` 존재 여부 확인 +2. JWT 서명 및 claim 검증 +3. `sid` 또는 `sub`로 로컬 세션 탐색 +4. 세션 스토어에서 직접 세션 파기 +5. 성공 시 `2xx` 응답 + +을 수행해야 합니다. + +### 4. `logout_token` 검증 + +RP는 Baron이 노출하는 Back-Channel Logout JWKS로 `logout_token`을 검증해야 합니다. + +현재 Baron의 JWKS endpoint 예시는 다음과 같습니다. + +```text +GET /api/v1/auth/backchannel/jwks.json +``` + +검증 필수 항목: + +1. JWT 서명 검증 +2. `iss`가 Baron OIDC issuer와 일치 +3. `aud`에 현재 RP `client_id` 포함 +4. `iat` 존재 +5. `jti` 존재 +6. `events`에 `http://schemas.openid.net/event/backchannel-logout` 포함 +7. `nonce`가 없어야 함 +8. `sid` 또는 `sub`가 있어야 함 + +추가 권장 항목: + +- `jti` replay 방지 캐시 +- 시계 오차 허용 범위 설정 +- 검증 실패 시 `400` + +## 세션 종료 기준 + +### 권장 순서 + +1. `sid`로 매칭 시도 +2. 매칭 실패 시 `sub`로 fallback + +이 기준은 `SID Claim Required` 정책에 따라 달라집니다. + +### `SID Claim Required = true` + +- `logout_token`에 `sid`가 있어야만 처리 +- `sub` fallback 금지 +- `sid` 중심 세션 모델을 운영하는 RP에 적합 + +### `SID Claim Required = false` + +- `sid`가 있으면 우선 사용 +- `sid` 매칭이 안 되거나 `sid`가 없어도 `sub`로 fallback 가능 +- 실제 운영에서는 이 모드가 더 유연할 수 있음 + +## 세션 파기 방식 + +`Back-Channel Logout`에서는 현재 브라우저 요청의 `req.session.destroy()`로는 부족합니다. +반드시 **세션 스토어에서 session id를 찾아 직접 파기**해야 합니다. + +예: + +```text +store.destroy(rpSessionId) +``` + +필수 조건: + +- 로그아웃 대상 세션 ID를 매핑 테이블에서 찾을 수 있어야 함 +- 이미 삭제된 세션은 idempotent success 처리 + +## 권장 로그 항목 + +RP는 아래 정도의 로그를 남기는 것을 권장합니다. + +1. 요청 수신 +2. 토큰 검증 성공/실패 +3. `sid`, `sub`, `jti` +4. 매칭된 `rpSessionId` 목록 +5. 세션 파기 성공/실패 수 + +예시: + +```text +[백채널 로그아웃] 요청 수신 +[백채널 로그아웃] 토큰 검증 성공 +[백채널 로그아웃] 세션 탐색 결과 +[백채널 로그아웃] 세션 파기 완료 +[백채널 로그아웃] 처리 완료 +``` + +주의: +- raw `logout_token` 전체를 로그에 남기지 않습니다. +- access token, refresh token, cookie raw value도 남기지 않습니다. + +## 테스트 체크리스트 + +### 기본 성공 시나리오 + +1. server-side-app RP 로그인 +2. callback 후 `sid/sub -> rpSessionId` 매핑 생성 확인 +3. UserFront에서 `세션 종료` +4. Baron이 RP의 `Back-Channel Logout URI`로 POST +5. RP가 `logout_token` 검증 성공 +6. RP 세션 파기 성공 +7. 보호 페이지 접근 시 비로그인 상태 확인 + +### 확인 포인트 + +1. devfront에 `Back-Channel Logout URI`가 실제 저장됐는가 +2. Baron backend가 해당 URI에 실제로 도달 가능한가 +3. RP 로그에 `요청 수신`과 `토큰 검증 성공`이 찍히는가 +4. 세션 스토어에서 실제 세션이 삭제됐는가 +5. `SID Claim Required=true`일 때와 `false`일 때 결과가 의도대로 다른가 + +## 구현 예시 구조 + +Node.js/Express 기준 최소 구조 예시는 다음과 같습니다. + +```text +GET /login +GET /callback +GET /profile +GET /logout +POST /backchannel-logout +``` + +내부 저장 예시: + +```text +sidToSessionIds: Map> +subToSessionIds: Map> +sessionIdToBinding: Map +``` + +실제 분리 예시는 아래 데모 코드를 참고할 수 있습니다. + +- 백채널 로그아웃 모듈: `/home/kyy/workspace/baron-sso-server-side-demo/backchannel-logout.js` +- 데모 앱 엔트리포인트: `/home/kyy/workspace/baron-sso-server-side-demo/app.js` + +이 데모는: + +1. callback 이후 `registerSessionBinding()`으로 `sid/sub -> sessionId`를 등록 +2. `POST /backchannel-logout`에서 `handleBackchannelLogout`를 그대로 연결 +3. 로컬 `/logout` 또는 세션 정리 시 `removeSessionBinding()` 호출 + +구조로 동작합니다. + +## 자주 생기는 문제 + +### 1. `localhost`로는 안 되는데 입력은 저장됨 + +입력 validation을 통과하는 것과 Baron backend가 실제로 그 주소에 도달하는 것은 다릅니다. + +예: + +```text +http://localhost:4444/backchannel-logout +``` + +이 값은 backend 컨테이너 기준으로는 자기 자신을 가리킬 수 있습니다. Docker 환경에서는 Docker 서비스명 또는 사설 IP를 사용해야 할 수 있습니다. + +### 2. `sid`가 로그인 시 값과 다름 + +실제 운영에서는 `logout_token.sid`가 RP가 저장한 `sid`와 항상 같다고 가정하면 안 됩니다. + +따라서: + +1. `sid` 우선 +2. `sub` fallback + +구현을 권장합니다. 다만 보안 정책상 `SID Claim Required=true`를 선택한 경우에는 fallback 없이 `sid`만 사용해야 합니다. + +### 3. `client_secret` 또는 auth method가 잘못되어 callback에서 실패함 + +`server-side-app`은 confidential client이므로 아래 값이 정확해야 합니다. + +1. `client_id` +2. `client_secret` +3. `token_endpoint_auth_method` +4. `redirect_uri` + +이 중 하나라도 다르면 authorization code 교환 단계에서 실패할 수 있습니다. + +## 시퀀스 다이어그램 + +```mermaid +sequenceDiagram + autonumber + participant Browser as 브라우저 + participant RP as Server-Side RP + participant Baron as Baron SSO + participant Store as 세션 스토어 + + Browser->>RP: GET /login 호출 + RP->>Browser: Baron authorize endpoint로 리다이렉트 + Browser->>Baron: Authorization Code 로그인 + Baron->>Browser: /callback?code=... 으로 리다이렉트 + Browser->>RP: GET /callback 호출 + RP->>Baron: client_secret 포함 token 요청 + Baron-->>RP: ID Token / Access Token 반환 + RP->>Store: RP 세션 생성 + RP->>RP: registerSessionBinding(sessionId, sid, sub) + RP-->>Browser: 로그인 완료 응답 + + Browser->>Baron: UserFront 또는 연동 서비스에서 세션 종료 + Baron->>RP: POST /backchannel-logout (logout_token) + RP->>Baron: Back-Channel JWKS로 logout_token 검증 + Baron-->>RP: 서명 / issuer / audience 검증 기준 제공 + RP->>RP: sid 또는 sub로 sessionId 탐색 + RP->>Store: destroy(sessionId) + RP->>RP: removeSessionBinding(sessionId) + RP-->>Baron: 200 OK + + Browser->>RP: GET /profile 호출 + RP-->>Browser: 루트 리다이렉트 또는 비로그인 응답 +``` From 9a87af93f18984f4d3d803700d697bd9e97f1eea Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 16:31:18 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EC=A0=84=EB=AC=B8=20locale=20fallback=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=81=EB=AC=B8=20=EB=B3=B8=EB=AC=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 181 +++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 8cf6946c..135dc419 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -838,11 +838,19 @@ class _SignupScreenState extends State { static String _resolveAgreementText( String key, { required String fallback, + String? englishFallback, required Set placeholders, }) { final localized = tr(key, fallback: '').trim(); - if (localized.isEmpty || placeholders.contains(localized)) { - return fallback; + final hasCorruptedEscapes = RegExp(r'\\{3,}').hasMatch(localized); + final preferredLocaleCode = resolvePreferredLocaleCode(); + final useEnglishFallback = + preferredLocaleCode.startsWith('en') && englishFallback != null; + if ( + localized.isEmpty || + placeholders.contains(localized) || + hasCorruptedEscapes) { + return useEnglishFallback ? englishFallback : fallback; } return localized; } @@ -918,10 +926,106 @@ class _SignupScreenState extends State { 본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다. 부칙 본 약관은 2024년 10월 1일부터 시행됩니다. +"""; + const englishFallback = """ +Baron Software Terms of Service + +Chapter 1. General Provisions +Article 1 (Purpose) +These Terms of Service define the rights, obligations, responsibilities, and other necessary matters between Baron Consultant Co., Ltd. (the "Company") and users in connection with the use of Baron Software and related services (the "Service"). + +Article 2 (Definitions) +1. "Service" means the software and related services provided by the Company. +2. "User" means any member or non-member who accesses and uses the Service. +3. "Member" means a person who agrees to these Terms and enters into a service agreement with the Company. +4. "Non-member" means a person who uses part of the Service without registering as a member. + +Article 3 (Effect and Amendment of the Terms) +These Terms take effect when the User agrees to them and the Company accepts the registration. The Company may amend these Terms when necessary, and amended Terms become effective after notice is provided through the Service. + +Article 4 (Governing Rules) +Matters not expressly provided in these Terms shall be governed by applicable laws of the Republic of Korea and general commercial practice. + +Chapter 2. Service Agreement +Article 5 (Formation of the Agreement) +The service agreement is formed when the User agrees to these Terms, submits the registration form provided by the Company, and the Company approves the registration. + +Article 6 (Reservation or Refusal of Registration) +The Company may reserve or refuse registration if the application contains false information or if it is technically difficult to provide the Service. + +Article 7 (Changes to User Information) +Members may review and edit their information at any time through the account management menu. Members must promptly update changed information and are responsible for problems arising from failure to do so. + +Chapter 3. Privacy Protection +Article 8 (Principles of Privacy Protection) +The Company protects Members' personal information in accordance with applicable laws. Detailed privacy matters are governed by the separate Privacy Policy. + +Article 9 (Compliance with the Privacy Policy) +The collection, use, disclosure, retention, and protection of personal information are governed by the Privacy Policy, which Users may review at any time. + +Article 10 (Children Under 14) +If the Company collects personal information from a child under the age of 14, the consent of a legal guardian is required. + +Chapter 4. Use of the Service +Article 11 (Provision of the Service) +The Company begins providing the Service once a registration request is approved. In principle, the Service is available 24 hours a day, 7 days a week. + +Article 12 (Change or Suspension of the Service) +The Company may change or suspend the Service after prior notice when provision of the Service becomes difficult. + +Chapter 5. Information and Advertising +Article 13 (Information and Advertising) +The Company may provide information and advertising considered necessary during use of the Service. Members may opt out of unwanted communications where permitted. + +Chapter 6. User Content +Article 14 (Management of Content) +The Company may delete content posted by a Member if it is illegal or violates these Terms. + +Article 15 (Copyright) +Copyright in content posted by Members belongs to the Member, but the Company may use such content for service promotion and improvement where permitted by law. + +Chapter 7. Termination and Restrictions +Article 16 (Termination) +Members may request termination of the agreement at any time, and the Company will process the request promptly. + +Article 17 (Restriction of Use) +The Company may restrict access to the Service if a Member violates these Terms. + +Chapter 8. Damages and Disclaimer +Article 18 (Damages) +The Company is not liable for damages arising from free services unless required by law. + +Article 19 (Disclaimer) +The Company is not liable where the Service cannot be provided due to force majeure such as natural disasters. + +Chapter 9. Paid Services +Article 20 (Use of Paid Services) +The Company may provide certain services for a fee. Pricing, payment methods, and refund procedures will be described on the service information page and payment screen. Fees are generally prepaid. + +Article 21 (Refund Policy) +Users may receive a full refund if they do not start using a paid service within 7 days after payment. Partial refunds may apply when service suspension occurs for reasons not attributable to the User. + +Article 22 (Suspension and Cancellation of Paid Services) +Members who wish to cancel a paid service must submit a cancellation request through customer support. The Company may immediately suspend and terminate paid services if the Member violates these Terms or uses the service improperly. + +Chapter 10. No Assignment +Article 23 (No Assignment) +Members may not assign, transfer, donate, or pledge their right to use the Service or their contractual status to a third party. + +Chapter 11. Governing Court +Article 24 (Dispute Resolution) +If a dispute arises in connection with the use of the Service, the Company and the Member shall make good-faith efforts to resolve it. + +Article 25 (Jurisdiction) +Any dispute arising under these Terms shall be subject to the exclusive jurisdiction of the Seoul Central District Court. + +Supplementary Provision +These Terms take effect on October 1, 2024. """; return _resolveAgreementText( 'msg.userfront.signup.tos_full', fallback: fallback, + englishFallback: englishFallback, placeholders: {'서비스 이용약관 전문...', 'Tos Full'}, ); } @@ -1035,10 +1139,83 @@ class _SignupScreenState extends State { 회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다. 제8조 (기타) 본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다. +"""; + const englishFallback = """ +Consent to Collection and Use of Personal Information + +Baron Service Privacy Policy + +Article 1 (Purpose) +Baron Consultant Co., Ltd. (the "Company") establishes this Privacy Policy to protect the personal information of customers and users of Baron Service (the "Service") and to fulfill its duties under the Personal Information Protection Act and other applicable laws. + +Article 2 (Purposes of Processing Personal Information) +The Company processes personal information for the following purposes: +- identity verification for registration and account management +- communication by phone or email +- provision of notices and operation of the Service +- delivery of product materials +- consultation and demo requests +- event participation and seminar guidance +- delivery of security guidance materials +- technical support +- service improvement feedback +- marketing communications for users who have separately agreed + +Article 3 (Retention Period) +The Company retains and uses personal information within the period required by law or agreed to by the data subject. +- member information: from registration until 1 year after account deletion +- promotional, consultation, and contract-related information: 2 years + +Article 4 (Provision to Third Parties) +The Company processes personal information only within the scope described in this Policy and provides it to third parties only where consent has been obtained or where required by law. + +Article 5 (Entrustment of Processing) +The Company does not currently entrust personal information processing to external processors for the core scope described here. If outsourcing becomes necessary, the Company will provide notice and obtain consent where required. + +Article 6 (Rights of Data Subjects) +Data subjects may request access, correction, deletion, suspension of processing, and other rights permitted by law. Requests may be submitted in writing, by email, or by facsimile. The Company may verify the identity or authority of the requester. + +Article 7 (Items of Personal Information Processed) +The Company may process the following items: +- required: name, mobile phone number, email address +- optional: company telephone number, inquiry details +- collection channels: website, phone, email + +Article 8 (Destruction of Personal Information) +When personal information is no longer needed due to expiration of the retention period or achievement of the processing purpose, the Company destroys it without delay. Electronic records are deleted using technically appropriate methods, and paper documents are shredded or incinerated. + +Article 9 (Security Measures) +The Company implements administrative, technical, and physical safeguards, including internal management plans, employee training, access control, encryption where appropriate, security software, and restricted access to facilities. + +Article 10 (Automatic Collection Devices) +The Company does not use cookies for this Service in the scope described here. + +Article 11 (Chief Privacy Officer) +The Company designates a privacy officer responsible for overall personal information protection and complaint handling. + +Article 12 (Requests for Access) +Data subjects may submit requests for access to personal information to the department designated by the Company, and the Company will make reasonable efforts to respond promptly. + +Article 13 (Remedies for Rights Infringement) +Data subjects may seek dispute resolution or consultation from competent authorities and institutions handling personal information disputes and complaints. + +Article 14 (Changes to This Privacy Policy) +If this Policy is added to, deleted from, or otherwise modified due to changes in law, policy, or security technology, the Company will provide advance notice before the effective date. + +Supplementary Provisions +1. Effective Date +This Privacy Policy takes effect on October 1, 2024. +2. Notice of Amendments +The Company will notify users of amendments through service notices, the website, or email as appropriate. +3. Severability +If any part of this Policy is held invalid or unenforceable, the remaining provisions will remain effective. +4. Miscellaneous +Matters not expressly provided in this Policy are governed by the Company's internal policies and applicable laws. """; return _resolveAgreementText( 'msg.userfront.signup.privacy_full', fallback: fallback, + englishFallback: englishFallback, placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'}, ); } From 64cdef81a6a1c60807e2dc245c13e1d9eb05ab21 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 16:31:40 +0900 Subject: [PATCH 09/15] =?UTF-8?q?i18n=20=EB=88=84=EB=9D=BD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=B9=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/ForbiddenMessage.tsx | 16 ++--- devfront/src/components/layout/AppLayout.tsx | 36 +++++----- .../lib/core/constants/error_whitelist.dart | 23 +++--- .../lib/core/services/auth_proxy_service.dart | 46 ++++++------ .../auth/presentation/error_screen.dart | 43 ++++++----- userfront/lib/i18n_data.dart | 71 +++++++++++++++++++ 6 files changed, 154 insertions(+), 81 deletions(-) diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index 97c2af01..43dee424 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -14,34 +14,34 @@ export function ForbiddenMessage({ resourceToken }: Props) { let explanation = t( "msg.dev.forbidden.default", - "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.", + "You do not have permission to access this resource. Contact your administrator.", ); if (role === "rp_admin") { explanation = t( "msg.dev.forbidden.rp_admin", - "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.", + "RP administrators can only access resources for their assigned applications.", ); } else if (role === "tenant_admin") { explanation = t( "msg.dev.forbidden.tenant_admin", - "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.", + "Your tenant administrator permission is missing, misconfigured, or expired.", ); } else if (role === "user" || role === "tenant_member") { if (resourceToken === "consents") { explanation = t( "msg.dev.forbidden.user.consents", - "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Viewing consent records for this application requires an RP administrator, consent read, or consent revoke relationship. Request access from an administrator if needed.", ); } else if (resourceToken === "audit") { explanation = t( "msg.dev.forbidden.user.audit", - "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Viewing audit logs for this application requires an RP administrator or audit read relationship. Request access from an administrator if needed.", ); } else { explanation = t( "msg.dev.forbidden.user.clients", - "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + "Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target RP. Request access from an administrator if needed.", ); } } @@ -51,9 +51,9 @@ export function ForbiddenMessage({ resourceToken }: Props) { ? t("ui.dev.audit.title", "Audit Logs") : resourceToken === "consents" ? t("ui.dev.clients.consents.title", "User Consent Grants") - : t("ui.dev.clients.registry.subtitle", "연동 앱"); + : t("ui.dev.clients.registry.subtitle", "Connected Applications"); - const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", { + const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", { resource: resourceLabel, }); diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index be1da833..0f5e1c71 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -32,7 +32,7 @@ const navItems = [ }, { labelKey: "ui.dev.nav.developer_request", - labelFallback: "개발자 권한 신청", + labelFallback: "Developer Access Request", to: "/developer-requests", icon: ClipboardCheck, }, @@ -71,7 +71,11 @@ function AppLayout() { }); const handleLogout = () => { - if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) { + if ( + window.confirm( + t("msg.dev.logout_confirm", "Are you sure you want to log out?"), + ) + ) { auth.removeUser(); navigate("/login"); } @@ -136,7 +140,7 @@ function AppLayout() { try { await auth.signinSilent(); } catch (error) { - console.error("세션 자동 연장에 실패했습니다.", error); + console.error("Silent session renewal failed.", error); } finally { isRenewInFlightRef.current = false; } @@ -184,7 +188,7 @@ function AppLayout() { try { await auth.signinSilent(); } catch (error) { - console.error("세션 무제한 유지 갱신에 실패했습니다.", error); + console.error("Unlimited session keepalive renewal failed.", error); } finally { isRenewInFlightRef.current = false; } @@ -241,7 +245,7 @@ function AppLayout() { void auth .signinSilent() .catch((error) => { - console.error("세션 자동 연장에 실패했습니다.", error); + console.error("Silent session renewal failed.", error); }) .finally(() => { isRenewInFlightRef.current = false; @@ -289,15 +293,15 @@ function AppLayout() { let sessionToneClass = "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; - let sessionText = t("ui.dev.session.active", "세션 활성"); + let sessionText = t("ui.dev.session.active", "Session active"); if (remainingMs === null) { sessionToneClass = "border-border bg-card text-muted-foreground"; - sessionText = t("ui.dev.session.unknown", "알 수 없음"); + sessionText = t("ui.dev.session.unknown", "Unknown"); } else if (remainingMs <= 0) { sessionToneClass = "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300"; - sessionText = t("ui.dev.session.expired", "세션 만료"); + sessionText = t("ui.dev.session.expired", "Session expired"); } else if ( remainingMinutes !== null && remainingSeconds !== null && @@ -307,7 +311,7 @@ function AppLayout() { "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; sessionText = t( "ui.dev.session.expiring", - "만료 임박: {{minutes}}분 {{seconds}}초 남음", + "Expiring soon: {{minutes}}m {{seconds}}s left", { minutes: remainingMinutes, seconds: remainingSeconds, @@ -316,7 +320,7 @@ function AppLayout() { } else { sessionText = t( "ui.dev.session.remaining", - "만료 예정: {{minutes}}분 {{seconds}}초 남음", + "Expires in {{minutes}}m {{seconds}}s", { minutes: remainingMinutes ?? 0, seconds: remainingSeconds ?? 0, @@ -343,7 +347,7 @@ function AppLayout() {

- {t("ui.dev.brand", "Baron 로그인")} + {t("ui.dev.brand", "Baron Sign In")}

{t("ui.dev.console_title", "Developer Console")} @@ -423,7 +427,7 @@ function AppLayout() { type="button" onClick={toggleTheme} className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20" - aria-label={t("ui.common.theme_toggle", "테마 전환")} + aria-label={t("ui.common.theme_toggle", "Toggle theme")} > {theme === "light" ? : } {theme === "light" @@ -447,7 +451,7 @@ function AppLayout() { className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20" aria-haspopup="menu" aria-expanded={isProfileMenuOpen} - aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")} + aria-label={t("ui.dev.profile.menu_aria", "Open account menu")} >
{profileInitial} @@ -496,14 +500,14 @@ function AppLayout() {

- {t("ui.dev.session.auto_extend", "세션 만료 관리")} + {t("ui.dev.session.auto_extend", "Session expiry")}

{isSessionExpiryEnabled ? sessionText : t( "ui.dev.session.disabled", - "세션 만료 비활성화", + "Session expiry disabled", )}

@@ -539,7 +543,7 @@ function AppLayout() { }} > - {t("ui.dev.profile.title", "내 정보")} + {t("ui.dev.profile.title", "My Profile")}