diff --git a/.gitignore b/.gitignore index a4f12e27..100c8e61 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # General .env +.env_backup .temp .DS_Store .idea/ diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx index fd87b545..d54922dd 100644 --- a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -72,6 +72,13 @@ const AVAILABLE_SCOPES = [ descKey: "msg.admin.api_keys.scopes.tenant_write.desc", descFallback: "테넌트 정보를 직접 제어합니다.", }, + { + id: "org-context:read", + labelKey: "ui.admin.api_keys.scopes.org_context_read.title", + labelFallback: "조직 Context 조회", + descKey: "msg.admin.api_keys.scopes.org_context_read.desc", + descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.", + }, ]; function ApiKeyCreatePage() { diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx index 8dd1f01e..9fc4b080 100644 --- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx +++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx @@ -7,6 +7,7 @@ import { ChevronDown, ChevronRight, CornerDownRight, + Download, ExternalLink, FolderOpen, LayoutDashboard, @@ -72,6 +73,7 @@ import { type TenantSummary, type UserSummary, createUser, + exportTenantsCSV, fetchTenants, fetchUsers, updateTenant, @@ -422,6 +424,24 @@ function TenantUserGroupsTab() { const [isAddExistingOpen, setIsAddExistingOpen] = useState(false); const [existingSearch, setExistingSearch] = useState(""); + const exportChildrenMutation = useMutation({ + mutationFn: (parentId: string) => exportTenantsCSV(true, parentId), + onSuccess: ({ blob, filename }) => { + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + onError: () => + toast.error( + t("msg.admin.tenants.export_error", "테넌트 내보내기에 실패했습니다."), + ), + }); + // Data Fetching const { data: allTenantsData, @@ -611,6 +631,16 @@ function TenantUserGroupsTab() { {t("ui.admin.users.list.add", "멤버 추가")} + diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 321a93e7..b6986246 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -241,9 +241,9 @@ export async function deleteTenantsBulk(ids: string[]) { }); } -export async function exportTenantsCSV(includeIds = false) { +export async function exportTenantsCSV(includeIds = false, parentId?: string) { const response = await apiClient.get("/v1/admin/tenants/export", { - params: { includeIds }, + params: { includeIds, parentId: parentId || undefined }, responseType: "blob", }); const dispositionHeader = response.headers["content-disposition"]; diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 126eda1c..c5004fb9 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -616,6 +616,69 @@ test.describe("Tenants Management", () => { ).toBeVisible(); }); + test("should export selected tenant children with UUIDs from organization tab", async ({ + page, + }) => { + const parentId = "11111111-2222-4333-8444-555555555555"; + const childId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let exportUrl = ""; + const mockTenants = [ + { + id: parentId, + name: "Parent Org", + slug: "parent-org", + status: "active", + type: "COMPANY", + memberCount: 5, + parentId: null, + }, + { + id: childId, + name: "Child Org", + slug: "child-org", + status: "active", + type: "ORGANIZATION", + memberCount: 2, + parentId, + }, + ]; + + await page.route("**/api/v1/admin/tenants**", async (route) => { + const url = route.request().url(); + const headers = { "Access-Control-Allow-Origin": "*" }; + if (url.includes("/export")) { + exportUrl = url; + return route.fulfill({ + body: "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n", + contentType: "text/csv", + headers: { + ...headers, + "Content-Disposition": 'attachment; filename="tenants.csv"', + }, + }); + } + if (url.includes(`/admin/tenants/${parentId}`)) { + return route.fulfill({ json: mockTenants[0], headers }); + } + return route.fulfill({ + json: { items: mockTenants, total: 2, limit: 1000, offset: 0 }, + headers, + }); + }); + + await page.goto(`/tenants/${parentId}/organization`); + await expect(page.getByRole("heading", { name: "Child Org" })).toBeVisible({ + timeout: 20000, + }); + + const download = page.waitForEvent("download"); + await page.getByTestId("tenant-subtree-export-btn").click(); + await download; + + expect(exportUrl).toContain("includeIds=true"); + expect(exportUrl).toContain(`parentId=${parentId}`); + }); + test("should show tenant UUID at the top of tenant detail profile", async ({ page, }) => { diff --git a/backend/cmd/server/error_handler_test.go b/backend/cmd/server/error_handler_test.go index 1e36b241..1a39d94f 100644 --- a/backend/cmd/server/error_handler_test.go +++ b/backend/cmd/server/error_handler_test.go @@ -117,15 +117,15 @@ func TestNewErrorHandler_MapsUnauthorizedCode(t *testing.T) { } } -func TestShouldEnableDocs_DisabledInProductionLikeEnv(t *testing.T) { +func TestShouldEnableDocs_DisabledOnlyInProduction(t *testing.T) { testCases := []struct { appEnv string want bool }{ {appEnv: "production", want: false}, {appEnv: "prod", want: false}, - {appEnv: "stage", want: false}, - {appEnv: "staging", want: false}, + {appEnv: "stage", want: true}, + {appEnv: "staging", want: true}, {appEnv: "dev", want: true}, {appEnv: "development", want: true}, } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c7fcd6d2..6c5788f5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -80,7 +80,8 @@ func normalizeDocsPrefix(prefix string) string { } func shouldEnableDocs(appEnv string) bool { - return !logger.IsProductionLikeEnv(appEnv) + env := strings.ToLower(strings.TrimSpace(appEnv)) + return env != "prod" && env != "production" } func registerDocsRoutes(app *fiber.App, prefix string) { @@ -622,6 +623,10 @@ func main() { api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) api.Get("/admin/worksmobile/oauth/callback", worksmobileHandler.OAuthCallback) + integrationsAPI := api.Group("/integrations") + integrationsAPI.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) + integrationsAPI.Get("/org-context", tenantHandler.GetOrgContext) + // Tenant Context Middleware (identifies tenant from Host header) api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{ TenantService: tenantService, diff --git a/backend/cmd/server/openapi_static_test.go b/backend/cmd/server/openapi_static_test.go new file mode 100644 index 00000000..0090deeb --- /dev/null +++ b/backend/cmd/server/openapi_static_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestOpenAPIDocumentsExternalAPIs(t *testing.T) { + data, err := os.ReadFile("../../docs/openapi.yaml") + require.NoError(t, err) + spec := string(data) + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(data, &parsed)) + + required := []string{ + "/.well-known/baron-rp-manifest.json:", + "/.well-known/baron-rp-manifest.schema.json:", + "/api/v1/public/orgchart:", + "/api/v1/tenants/registration:", + "/api/v1/integrations/org-context:", + "/api/v1/admin/api-keys:", + "/api/v1/admin/api-keys/{id}:", + "BaronApiKeyId:", + "BaronApiKeySecret:", + "X-Baron-Key-ID", + "X-Baron-Key-Secret", + "API Key 인증이 필요한 요청의 header에 자동으로 포함됩니다.", + "OrgContextResponse:", + } + for _, expected := range required { + require.Contains(t, spec, expected) + } + + require.False(t, strings.Contains(spec, "/api/v1/orgfront/org-context:")) +} diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 26b39c38..8c9b5e6d 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -23,6 +23,10 @@ tags: description: 세션 관리(계획) - name: Admin description: 관리자 기능/테넌트 + - name: Public + description: 인증 없이 외부에 노출되는 공개 API + - name: Integrations + description: API Key 기반 외부 연동 API - name: Dev description: RP/Consent 관리 - name: Audit @@ -31,6 +35,46 @@ tags: description: 외부 게이트웨이 연동 paths: + /.well-known/baron-rp-manifest: + get: + tags: [Public] + summary: RP IAM Manifest HTML + description: 외부 RP가 Baron SSO/IAM 연동 정책을 사람이 읽을 수 있는 HTML로 확인합니다. + responses: + "200": + description: HTML + content: + text/html: + schema: + type: string + + /.well-known/baron-rp-manifest.json: + get: + tags: [Public] + summary: RP IAM Manifest JSON + description: 외부 RP가 Baron SSO/IAM 연동 정책을 machine-readable JSON으로 조회합니다. + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/RPManifestResponse" + + /.well-known/baron-rp-manifest.schema.json: + get: + tags: [Public] + summary: RP IAM Manifest JSON Schema + description: RP IAM Manifest JSON의 JSON Schema를 조회합니다. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + additionalProperties: true + /health: get: tags: [System] @@ -79,6 +123,108 @@ paths: schema: type: string + /api/v1/public/orgchart: + get: + tags: [Public] + summary: 공유 링크 조직도 조회 + description: 공유 링크 token으로 공개 가능한 조직도 데이터를 조회합니다. + parameters: + - in: query + name: token + required: true + schema: + type: string + description: 조직도 공유 링크 token + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PublicOrgChartResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/tenants/registration: + post: + tags: [Public] + summary: 테넌트 등록 요청 + description: 외부에서 테넌트 등록을 요청하고 승인 대기 상태로 생성합니다. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TenantRegistrationRequest" + responses: + "202": + description: Accepted + content: + application/json: + schema: + $ref: "#/components/schemas/TenantRegistrationResponse" + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/integrations/org-context: + get: + tags: [Integrations] + summary: 조직 Context 조회 + description: | + 외부 연동앱이 계정 세션 없이 API Key로 Baron SSO 조직 subtree를 조회합니다. + `tenantSlug`가 없으면 `hanmac-family` 전체 subtree를 반환하며, slug 기반 지정만 계약으로 지원합니다. + security: + - BaronApiKeyId: [] + BaronApiKeySecret: [] + parameters: + - in: query + name: tenantSlug + required: false + schema: + type: string + default: hanmac-family + description: 조회할 subtree root tenant slug + - in: query + name: includeUsers + required: false + schema: + type: boolean + default: true + description: false이면 users와 directUserIds를 비워 반환합니다. + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OrgContextResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Tenant slug not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/auth/password/login: post: tags: [Auth] @@ -614,6 +760,40 @@ paths: schema: $ref: "#/components/schemas/TenantResponse" + /api/v1/admin/tenants/export: + get: + tags: [Admin] + summary: 테넌트 CSV 내보내기 + description: | + super admin 전용 CSV export입니다. `parentId`를 지정하면 해당 tenant의 모든 descendant를 leaf까지 포함해 내보냅니다. + parameters: + - in: query + name: includeIds + required: false + schema: + type: boolean + default: false + description: true이면 tenant_id와 parent_tenant_id 컬럼을 포함합니다. + - in: query + name: parentId + required: false + schema: + type: string + description: 하위 subtree를 내보낼 parent tenant ID + responses: + "200": + description: CSV + content: + text/csv: + schema: + type: string + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/admin/tenants/{id}: get: tags: [Admin] @@ -666,6 +846,82 @@ paths: "204": description: No Content + /api/v1/admin/api-keys: + get: + tags: [Admin] + summary: API Key 목록 + description: super admin 전용 API Key 관리 화면에서 사용합니다. + parameters: + - in: query + name: limit + required: false + schema: + type: integer + default: 50 + - in: query + name: offset + required: false + schema: + type: integer + default: 0 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKeyListResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + post: + tags: [Admin] + summary: API Key 생성 + description: super admin만 API Key를 생성할 수 있으며, clientSecret은 생성 응답에서 한 번만 반환됩니다. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKeyCreateRequest" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/ApiKeyCreateResponse" + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/admin/api-keys/{id}: + delete: + tags: [Admin] + summary: API Key 삭제 + description: super admin 전용 API Key 삭제입니다. + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "204": + description: No Content + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/dev/clients: get: tags: [Dev] @@ -843,6 +1099,22 @@ paths: description: OK components: + securitySchemes: + BaronApiKeyId: + type: apiKey + in: header + name: X-Baron-Key-ID + description: | + API Key client ID입니다. Swagger UI 우측 상단 `Authorize`에서 `X-Baron-Key-ID` 값으로 입력하면 + API Key 인증이 필요한 요청의 header에 자동으로 포함됩니다. + BaronApiKeySecret: + type: apiKey + in: header + name: X-Baron-Key-Secret + description: | + API Key client secret입니다. Swagger UI 우측 상단 `Authorize`에서 `X-Baron-Key-Secret` 값으로 입력하면 + API Key 인증이 필요한 요청의 header에 자동으로 포함됩니다. + schemas: ErrorResponse: type: object @@ -871,6 +1143,210 @@ components: checks: type: object + RPManifestResponse: + type: object + additionalProperties: true + properties: + version: + type: string + example: "2026-05-11" + issuer: + type: string + example: https://sso.hmac.kr + oidc: + type: object + additionalProperties: true + iam: + type: object + additionalProperties: true + identity_contract: + type: object + additionalProperties: true + trusted_headers: + type: object + additionalProperties: true + + PublicOrgChartUser: + type: object + properties: + id: + type: string + name: + type: string + position: + type: string + jobTitle: + type: string + companyCode: + type: string + status: + type: string + + PublicOrgChartResponse: + type: object + properties: + tenants: + type: array + items: + $ref: "#/components/schemas/TenantResponse" + users: + type: array + items: + $ref: "#/components/schemas/PublicOrgChartUser" + sharedWith: + type: string + + TenantRegistrationRequest: + type: object + required: [name, domain, adminEmail] + properties: + name: + type: string + slug: + type: string + description: + type: string + domain: + type: string + adminEmail: + type: string + format: email + + TenantRegistrationResponse: + type: object + properties: + message: + type: string + tenant: + $ref: "#/components/schemas/TenantResponse" + + OrgContextScope: + type: object + properties: + tenantId: + type: string + example: 01970f08-91da-7286-bd19-882fb98d1f2c + tenantSlug: + type: string + example: hanmac-family + + OrgContextTenant: + type: object + properties: + id: + type: string + example: 01970f08-91da-7286-bd19-882fb98d1f2c + type: + type: string + example: COMPANY_GROUP + name: + type: string + example: 한맥가족 + slug: + type: string + example: hanmac-family + parentId: + type: string + nullable: true + status: + type: string + example: active + description: + type: string + domains: + type: array + items: + type: string + memberCount: + type: integer + visibility: + type: string + example: public + orgUnitType: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + OrgContextTreeNode: + allOf: + - $ref: "#/components/schemas/OrgContextTenant" + - type: object + properties: + directUserIds: + type: array + items: + type: string + children: + type: array + items: + $ref: "#/components/schemas/OrgContextTreeNode" + + OrgContextUser: + type: object + properties: + id: + type: string + email: + type: string + format: email + name: + type: string + role: + type: string + status: + type: string + tenantIds: + type: array + items: + type: string + tenantSlugs: + type: array + items: + type: string + department: + type: string + grade: + type: string + position: + type: string + jobTitle: + type: string + metadata: + type: object + additionalProperties: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + OrgContextResponse: + type: object + properties: + schemaVersion: + type: string + example: baron.org-context.v1 + issuedAt: + type: string + format: date-time + scope: + $ref: "#/components/schemas/OrgContextScope" + tree: + $ref: "#/components/schemas/OrgContextTreeNode" + tenants: + type: array + items: + $ref: "#/components/schemas/OrgContextTenant" + users: + type: array + items: + $ref: "#/components/schemas/OrgContextUser" + PasswordLoginRequest: type: object required: [loginId, password] @@ -1268,6 +1744,61 @@ components: status: type: string + ApiKeySummary: + type: object + properties: + id: + type: string + name: + type: string + client_id: + type: string + scopes: + type: array + items: + type: string + example: [org-context:read] + status: + type: string + example: active + lastUsedAt: + type: string + nullable: true + createdAt: + type: string + format: date-time + + ApiKeyListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/ApiKeySummary" + total: + type: integer + + ApiKeyCreateRequest: + type: object + required: [name] + properties: + name: + type: string + scopes: + type: array + items: + type: string + example: [org-context:read] + + ApiKeyCreateResponse: + type: object + properties: + apiKey: + $ref: "#/components/schemas/ApiKeySummary" + clientSecret: + type: string + description: 생성 시점에 한 번만 반환되는 plain secret + ClientSummary: type: object properties: diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3c41a9d9..e9420cf3 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -704,7 +704,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable") } - // [New Policy] Enforce Explicit Tenant Assignment (No Auto-Provisioning) + // 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다. companyCode := "" var tenantID *string @@ -765,6 +765,14 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { if tenantID == nil && req.AffiliationType == "AFFILIATE" { return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.") } + if tenantID == nil && req.AffiliationType == "GENERAL" { + tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email) + if err != nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") + } + companyCode = tenant.Slug + tenantID = &tenant.ID + } // Normalize Phone (E.164 형태로 보관) normalizedPhone := strings.ReplaceAll(req.Phone, "-", "") @@ -785,6 +793,9 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { "grade": "", "role": domain.RoleUser, } + if tenantID != nil { + attributes["tenant_id"] = *tenantID + } // Sync all custom login IDs based on tenant schemas loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") @@ -1100,6 +1111,10 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID if traits == nil { return claims } + if tenantID == "" { + tenantID = representativeTenantIDFromTraits(traits) + } + includeTenantDetails := tenantClaimScopeRequested(scopes) scopeSet := map[string]struct{}{} for _, scope := range scopes { @@ -1200,53 +1215,38 @@ func buildOidcClaimsFromTraits(traits map[string]any, scopes []string, tenantID // [New] Dynamic Claim Injection for Multi-tenancy if tenantID != "" { claims["tenant_id"] = tenantID - // Extract namespaced metadata if available - // The key in traits is expected to be the tenantID - if namespaced, ok := traits[tenantID].(map[string]any); ok { - for k, v := range namespaced { - claims[k] = v - } - } else if namespaced, ok := traits[tenantID].(map[string]interface{}); ok { - for k, v := range namespaced { - claims[k] = v + if includeTenantDetails { + // tenant 스코프가 있을 때만 대표소속 namespace metadata를 top-level claim으로 펼칩니다. + if namespaced, ok := traits[tenantID].(map[string]any); ok { + for k, v := range namespaced { + claims[k] = v + } } } } // [Update] Pass ALL tenants the user belongs to allTenants := map[string]any{} - var joinedTenants []string + joinedTenants := joinedTenantIDsFromTraits(traits, tenantID) // Heuristic: if a trait value is a map, it's treated as namespaced metadata for a tenant for k, v := range traits { + if k == "metadata" { + continue + } if m, ok := v.(map[string]any); ok { allTenants[k] = m - joinedTenants = append(joinedTenants, k) - } else if m, ok := v.(map[string]interface{}); ok { - allTenants[k] = m - joinedTenants = append(joinedTenants, k) } } - // [Fix] Include primary tenant_id in joined_tenants if it's not already there - if primaryTenantID := getString("tenant_id"); primaryTenantID != "" { - found := false - for _, id := range joinedTenants { - if id == primaryTenantID { - found = true - break - } - } - if !found { - joinedTenants = append(joinedTenants, primaryTenantID) - } - } - - if len(allTenants) > 0 || len(joinedTenants) > 0 { - claims["tenants"] = allTenants + if len(joinedTenants) > 0 { claims["joined_tenants"] = joinedTenants } + if includeTenantDetails && len(allTenants) > 0 { + claims["tenants"] = allTenants + } + return claims } @@ -1268,6 +1268,311 @@ func composeOIDCSessionClaims(client domain.HydraClient, traits map[string]any, return withOidcSessionMetadata(claims, sessionID) } +func (h *AuthHandler) withHanmacFamilyTenantClaims(ctx context.Context, claims map[string]any, traits map[string]any, scopes []string) map[string]any { + if claims == nil { + claims = map[string]any{} + } + if h == nil || h.TenantService == nil { + return claims + } + + appointments := tenantClaimAppointmentsFromTraits(traits) + includeTenantDetails := tenantClaimScopeRequested(scopes) + tenants, hadTenantClaims := claims["tenants"].(map[string]any) + if !hadTenantClaims { + tenants = map[string]any{} + } + createdTenantClaims := map[string]bool{} + if tenantID := tenantClaimString(claims, "tenant_id"); tenantID != "" { + if _, exists := tenants[tenantID]; !exists { + tenants[tenantID] = map[string]any{} + createdTenantClaims[tenantID] = true + } + } + for _, tenantKey := range tenantClaimAppointmentPrimaryKeys(appointments) { + if _, exists := tenants[tenantKey]; !exists { + tenants[tenantKey] = map[string]any{} + createdTenantClaims[tenantKey] = true + } + } + if len(tenants) == 0 { + return claims + } + + leadTenantIDs := make([]string, 0) + joinedTenantIDs := make([]string, 0) + + for tenantKey, rawTenantClaim := range tenants { + tenantClaim, ok := rawTenantClaim.(map[string]any) + if !ok { + continue + } + + tenant, ancestors, inHanmacFamily := h.resolveHanmacFamilyTenantClaimAncestry(ctx, tenantKey) + if !inHanmacFamily || tenant == nil { + if createdTenantClaims[tenantKey] { + delete(tenants, tenantKey) + } + continue + } + joinedTenantIDs = append(joinedTenantIDs, tenant.ID) + if !includeTenantDetails { + if createdTenantClaims[tenantKey] { + delete(tenants, tenantKey) + } + continue + } + + tenantClaim["id"] = tenant.ID + tenantClaim["slug"] = tenant.Slug + tenantClaim["name"] = tenant.Name + tenantClaim["type"] = tenant.Type + tenantClaim["ancestors"] = ancestors + if len(ancestors) > 0 { + tenantClaim["parentTenantId"] = ancestors[0]["id"] + } else { + tenantClaim["parentTenantId"] = nil + } + delete(tenantClaim, "parentTenant") + + if appointment := lookupTenantClaimAppointment(appointments, tenantKey, tenant); appointment != nil { + mergeTenantAppointmentClaim(tenantClaim, appointment) + } + + if lead, ok := metadataBoolFromMap(tenantClaim, "lead", "isLead", "isOwner", "isManager"); ok { + tenantClaim["lead"] = lead + if lead { + leadTenantIDs = append(leadTenantIDs, tenant.ID) + } + } + if representative, ok := metadataBoolFromMap(tenantClaim, "representative", "isPrimary", "primary"); ok { + tenantClaim["representative"] = representative + tenantClaim["isPrimary"] = representative + } + + tenants[tenantKey] = tenantClaim + } + + if len(leadTenantIDs) > 0 { + claims["lead_tenants"] = uniqueSortedStrings(leadTenantIDs) + } + if len(joinedTenantIDs) > 0 { + claims["joined_tenants"] = mergeClaimStringList(claims["joined_tenants"], joinedTenantIDs) + } + if !includeTenantDetails { + if !hadTenantClaims { + delete(claims, "tenants") + } + delete(claims, "lead_tenants") + return claims + } + if len(tenants) > 0 { + claims["tenants"] = tenants + } else if !hadTenantClaims { + delete(claims, "tenants") + } + return claims +} + +func tenantClaimScopeRequested(scopes []string) bool { + for _, scope := range scopes { + if strings.EqualFold(strings.TrimSpace(scope), "tenant") { + return true + } + } + return false +} + +func mergeClaimStringList(raw any, values []string) []string { + merged := make([]string, 0, len(values)) + switch current := raw.(type) { + case []string: + merged = append(merged, current...) + case []any: + for _, item := range current { + if s, ok := item.(string); ok { + merged = append(merged, s) + } + } + } + merged = append(merged, values...) + return uniqueSortedStrings(merged) +} + +func tenantClaimAppointmentPrimaryKeys(appointments map[string]map[string]any) []string { + if len(appointments) == 0 { + return nil + } + seen := map[string]bool{} + keys := make([]string, 0, len(appointments)) + for _, appointment := range appointments { + for _, key := range []string{"tenantId", "tenant_id", "tenantSlug", "tenant_slug"} { + value := tenantClaimString(appointment, key) + if value == "" || seen[value] { + continue + } + seen[value] = true + keys = append(keys, value) + break + } + } + sort.Strings(keys) + return keys +} + +func tenantClaimAppointmentsFromTraits(traits map[string]any) map[string]map[string]any { + raw := rawAdditionalAppointments(traits) + if raw == nil { + return nil + } + items, ok := raw.([]any) + if !ok { + return nil + } + + appointments := make(map[string]map[string]any) + for _, item := range items { + appointment, ok := item.(map[string]any) + if !ok { + continue + } + for _, key := range []string{"tenantId", "tenant_id", "tenantSlug", "tenant_slug"} { + if id := tenantClaimString(appointment, key); id != "" { + appointments[id] = appointment + } + } + } + return appointments +} + +func rawAdditionalAppointments(traits map[string]any) any { + if traits == nil { + return nil + } + if raw, ok := traits["additionalAppointments"]; ok { + return raw + } + if metadata, ok := traits["metadata"].(map[string]any); ok { + return metadata["additionalAppointments"] + } + return nil +} + +func lookupTenantClaimAppointment(appointments map[string]map[string]any, tenantKey string, tenant *domain.Tenant) map[string]any { + if len(appointments) == 0 { + return nil + } + for _, key := range []string{tenantKey, tenant.ID, tenant.Slug} { + if appointment, ok := appointments[key]; ok { + return appointment + } + } + return nil +} + +func mergeTenantAppointmentClaim(tenantClaim map[string]any, appointment map[string]any) { + for _, key := range []string{"grade", "jobTitle", "job_title", "position"} { + if value := tenantClaimString(appointment, key); value != "" { + switch key { + case "job_title": + tenantClaim["jobTitle"] = value + default: + tenantClaim[key] = value + } + } + } + if lead, ok := metadataBoolFromMap(appointment, "lead", "isLead", "isOwner", "isManager"); ok { + tenantClaim["lead"] = lead + } + if representative, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok { + tenantClaim["representative"] = representative + tenantClaim["isPrimary"] = representative + } +} + +func tenantClaimString(values map[string]any, key string) string { + raw, ok := values[key] + if !ok || raw == nil { + return "" + } + switch value := raw.(type) { + case string: + return strings.TrimSpace(value) + default: + return strings.TrimSpace(fmt.Sprint(value)) + } +} + +func (h *AuthHandler) resolveHanmacFamilyTenantClaimAncestry(ctx context.Context, identifier string) (*domain.Tenant, []map[string]any, bool) { + tenant, err := h.resolveTenantClaimTenant(ctx, identifier) + if err != nil || tenant == nil { + return nil, nil, false + } + if strings.EqualFold(tenant.Slug, hanmacFamilyTenantSlug) { + return tenant, []map[string]any{}, true + } + + ancestors := make([]*domain.Tenant, 0) + visited := map[string]bool{tenant.ID: true} + current := tenant + for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" { + parentID := strings.TrimSpace(*current.ParentID) + if visited[parentID] { + return tenant, tenantClaimAncestorSummaries(ancestors), false + } + visited[parentID] = true + + parent, err := h.TenantService.GetTenant(ctx, parentID) + if err != nil || parent == nil { + return tenant, tenantClaimAncestorSummaries(ancestors), false + } + ancestors = append(ancestors, parent) + if strings.EqualFold(parent.Slug, hanmacFamilyTenantSlug) { + return tenant, tenantClaimAncestorSummaries(ancestors), true + } + current = parent + } + + return tenant, tenantClaimAncestorSummaries(ancestors), false +} + +func (h *AuthHandler) resolveTenantClaimTenant(ctx context.Context, identifier string) (*domain.Tenant, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return nil, errors.New("tenant identifier is required") + } + if tenant, err := h.TenantService.GetTenant(ctx, identifier); err == nil && tenant != nil { + return tenant, nil + } + return h.TenantService.GetTenantBySlug(ctx, identifier) +} + +func tenantClaimTenantSummary(tenant *domain.Tenant) map[string]any { + return map[string]any{ + "id": tenant.ID, + "slug": tenant.Slug, + "name": tenant.Name, + "type": tenant.Type, + } +} + +func tenantClaimAncestorSummaries(ancestors []*domain.Tenant) []map[string]any { + if len(ancestors) == 0 { + return []map[string]any{} + } + items := make([]map[string]any, 0, len(ancestors)) + for i, ancestor := range ancestors { + item := tenantClaimTenantSummary(ancestor) + if i+1 < len(ancestors) { + item["parentTenantId"] = ancestors[i+1].ID + } else { + item["parentTenantId"] = nil + } + items = append(items, item) + } + return items +} + func applyConfiguredIDTokenClaims(baseClaims map[string]any, metadata map[string]interface{}) map[string]any { if baseClaims == nil { baseClaims = map[string]any{} @@ -5535,19 +5840,14 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject) if err == nil && identity != nil { currentSessionID := h.resolveCurrentSessionID(c) - var tenantID string - if consentRequest.Client.Metadata != nil { - if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { - tenantID = tid - } - } sessionClaims := composeOIDCSessionClaims( consentRequest.Client, identity.Traits, consentRequest.RequestedScope, - tenantID, + representativeTenantIDFromTraits(identity.Traits), currentSessionID, ) + sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), challenge, consentRequest, sessionClaims) if err == nil { @@ -5571,10 +5871,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { // 신원 정보를 가져오지 못하면 자동 승인을 진행할 수 없으므로 일반 흐름(UI 노출)으로 진행 } else { currentSessionID := h.resolveCurrentSessionID(c) - var tenantID string + var clientTenantID string if consentRequest.Client.Metadata != nil { if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { - tenantID = tid + clientTenantID = tid } } @@ -5582,9 +5882,10 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { consentRequest.Client, identity.Traits, consentRequest.RequestedScope, - tenantID, + representativeTenantIDFromTraits(identity.Traits), currentSessionID, ) + sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용 - 자동 승인 시) @@ -5627,7 +5928,7 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error { EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, - TenantID: tenantID, // Uses the tenantID extracted earlier + TenantID: clientTenantID, SessionID: currentSessionID, EventType: "consent.granted", Status: "success", @@ -5761,11 +6062,10 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { c.Locals("login_id", loginID) } currentSessionID := h.resolveCurrentSessionID(c) - - var tenantID string + var clientTenantID string if consentRequest.Client.Metadata != nil { if tid, ok := consentRequest.Client.Metadata["tenant_id"].(string); ok { - tenantID = tid + clientTenantID = tid } } @@ -5773,9 +6073,10 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { consentRequest.Client, identity.Traits, consentRequest.RequestedScope, - tenantID, + representativeTenantIDFromTraits(identity.Traits), currentSessionID, ) + sessionClaims = h.withHanmacFamilyTenantClaims(c.Context(), sessionClaims, identity.Traits, consentRequest.RequestedScope) sessionClaims = h.withRPProfileClaims(c.Context(), sessionClaims, consentRequest.Client, consentRequest.Subject) // [Debug] 실제 생성된 클레임 출력 (요청사항 확인용) @@ -5821,7 +6122,7 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error { EventID: GenerateSecureToken(16), Timestamp: time.Now(), UserID: consentRequest.Subject, - TenantID: tenantID, // [New] Add TenantID to AuditLog + TenantID: clientTenantID, SessionID: currentSessionID, EventType: "consent.granted", Status: "success", diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 24d9223e..f051bc06 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -9,6 +9,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -175,7 +176,11 @@ type AsyncMockTenantService struct { } func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { - return nil, nil + args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) } func (m *AsyncMockTenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { @@ -284,9 +289,20 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) { mockRedis.On("Delete", phoneKey).Return(nil) // Tenant Mocks - validTenant := &domain.Tenant{ID: "t1", Slug: "example", Status: domain.TenantStatusActive} - mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(validTenant, nil) - mockTenant.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil) + personalTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-test", Type: domain.TenantTypePersonal, Status: domain.TenantStatusActive} + mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, nil) + mockTenant.On( + "RegisterTenant", + mock.Anything, + "Personal - test@example.com", + mock.MatchedBy(func(slug string) bool { return strings.HasPrefix(slug, "personal-") }), + domain.TenantTypePersonal, + "Automatically provisioned personal tenant", + []string(nil), + (*string)(nil), + "", + ).Return(personalTenant, nil) + mockTenant.On("GetTenant", mock.Anything, "personal-t1").Return(personalTenant, nil) // Kratos Mocks (Success) mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil) diff --git a/backend/internal/handler/auth_handler_dynamic_claims_test.go b/backend/internal/handler/auth_handler_dynamic_claims_test.go index 41e3f749..d74cfcf8 100644 --- a/backend/internal/handler/auth_handler_dynamic_claims_test.go +++ b/backend/internal/handler/auth_handler_dynamic_claims_test.go @@ -4,6 +4,7 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" + "context" "encoding/json" "io" "net/http" @@ -35,10 +36,11 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { claims := buildOidcClaimsFromTraits(traits, scopes, "") assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) + assert.Equal(t, "primary-tenant-999", claims["tenant_id"]) assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) - assert.NotNil(t, claims["tenants"]) + assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "tenant-2") assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") // Should contain primary @@ -48,11 +50,11 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { claims := buildOidcClaimsFromTraits(traits, scopes, "tenant-1") assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) - assert.Equal(t, "tenant-1", claims["tenant_id"]) // Dynamic tenant injection overwrites top-level for this context - assert.Equal(t, "개발팀", claims["department"]) - assert.Equal(t, "선임", claims["grade"]) + assert.Equal(t, "tenant-1", claims["tenant_id"]) + assert.Nil(t, claims["department"]) + assert.Nil(t, claims["grade"]) - assert.NotNil(t, claims["tenants"]) + assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "tenant-2") assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") @@ -63,10 +65,10 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { assert.Equal(t, "user@baron.com", claims["email"]) assert.Equal(t, "홍길동", claims["name"]) assert.Equal(t, "tenant-2", claims["tenant_id"]) - assert.Equal(t, "재무팀", claims["department"]) - assert.Equal(t, "팀장", claims["grade"]) + assert.Nil(t, claims["department"]) + assert.Nil(t, claims["grade"]) - assert.NotNil(t, claims["tenants"]) + assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") }) @@ -78,10 +80,53 @@ func TestBuildOidcClaimsFromTraits_DynamicClaims(t *testing.T) { assert.Nil(t, claims["department"]) assert.Nil(t, claims["grade"]) - assert.NotNil(t, claims["tenants"]) + assert.Nil(t, claims["tenants"]) assert.Contains(t, claims["joined_tenants"], "tenant-1") assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") }) + + t.Run("Tenant scope includes detailed tenant metadata", func(t *testing.T) { + claims := buildOidcClaimsFromTraits(traits, []string{"openid", "profile", "tenant"}, "tenant-1") + assert.Equal(t, "tenant-1", claims["tenant_id"]) + assert.Equal(t, "개발팀", claims["department"]) + assert.Equal(t, "선임", claims["grade"]) + assert.NotNil(t, claims["tenants"]) + assert.Contains(t, claims["joined_tenants"], "tenant-1") + assert.Contains(t, claims["joined_tenants"], "tenant-2") + assert.Contains(t, claims["joined_tenants"], "primary-tenant-999") + }) +} + +func TestRepresentativeTenantIDFromTraits(t *testing.T) { + t.Run("explicit tenant_id wins", func(t *testing.T) { + traits := map[string]any{ + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "additionalAppointments": []any{ + map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", "isPrimary": true}, + }, + } + assert.Equal(t, "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", representativeTenantIDFromTraits(traits)) + }) + + t.Run("primary appointment wins when tenant_id is absent", func(t *testing.T) { + traits := map[string]any{ + "additionalAppointments": []any{ + map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"}, + map[string]any{"tenantId": "01970f0c-8c44-7069-9f20-7d28c0b8e630", "representative": true}, + }, + } + assert.Equal(t, "01970f0c-8c44-7069-9f20-7d28c0b8e630", representativeTenantIDFromTraits(traits)) + }) + + t.Run("first appointment is fallback", func(t *testing.T) { + traits := map[string]any{ + "additionalAppointments": []any{ + map[string]any{"tenantId": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661"}, + map[string]any{"tenantId": "01970f0c-8c44-7069-9f20-7d28c0b8e630"}, + }, + } + assert.Equal(t, "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", representativeTenantIDFromTraits(traits)) + }) } func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { @@ -92,7 +137,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-dynamic" { return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "challenge": "challenge-dynamic", - "requested_scope": []string{"openid", "profile"}, + "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-123", "client": map[string]interface{}{ "client_id": "client-app", @@ -162,7 +207,7 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { reqBody, _ := json.Marshal(map[string]interface{}{ "consent_challenge": "challenge-dynamic", - "grant_scope": []string{"openid", "profile"}, + "grant_scope": []string{"openid", "profile", "tenant"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") @@ -179,6 +224,293 @@ func TestAcceptConsentRequest_DynamicClaims(t *testing.T) { assert.Equal(t, "Architect", capturedClaims["position"]) } +func TestAcceptConsentRequest_UsesRepresentativeTenantIDInsteadOfClientTenantContext(t *testing.T) { + var capturedClaims map[string]interface{} + + representativeTenantID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" + rpContextTenantID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-representative-tenant", + "requested_scope": []string{"openid", "profile", "tenant"}, + "subject": "user-representative", + "client": map[string]interface{}{ + "client_id": "client-app", + "metadata": map[string]interface{}{ + "tenant_id": rpContextTenantID, + }, + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-representative-tenant" { + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]interface{} + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]interface{}); ok { + capturedClaims = session["id_token"].(map[string]interface{}) + } + + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-representative").Return(&service.KratosIdentity{ + ID: "user-representative", + Traits: map[string]interface{}{ + "email": "user@test.com", + "name": "Test User", + "additionalAppointments": []interface{}{ + map[string]interface{}{"tenantId": representativeTenantID, "isPrimary": true}, + map[string]interface{}{"tenantId": rpContextTenantID}, + }, + }, + }, nil) + + app := fiber.New() + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "consent_challenge": "challenge-representative-tenant", + "grant_scope": []string{"openid", "profile"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.NotNil(t, capturedClaims) + assert.Equal(t, representativeTenantID, capturedClaims["tenant_id"]) + assert.Contains(t, capturedClaims["joined_tenants"], representativeTenantID) + assert.Contains(t, capturedClaims["joined_tenants"], rpContextTenantID) + assert.Nil(t, capturedClaims["tenants"]) +} + +func TestAcceptConsentRequest_IncludesHanmacFamilyTenantClaimDetails(t *testing.T) { + var capturedClaims map[string]interface{} + deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" + secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + companyID := "01970f08-91da-7286-bd19-882fb98d1f2c" + rootID := "01970f07-4f01-7d9a-a71e-b53ad508f345" + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "challenge": "challenge-hanmac-tenant-claim", + "requested_scope": []string{"openid", "profile", "tenant"}, + "subject": "user-hanmac", + "client": map[string]interface{}{ + "client_id": "hanmac-rp", + "metadata": map[string]interface{}{ + "tenant_id": deptID, + }, + }, + }), nil + } + if r.URL.Path == "/oauth2/auth/requests/consent/accept" && r.URL.Query().Get("consent_challenge") == "challenge-hanmac-tenant-claim" { + body, _ := io.ReadAll(r.Body) + var acceptReq map[string]interface{} + json.Unmarshal(body, &acceptReq) + if session, ok := acceptReq["session"].(map[string]interface{}); ok { + capturedClaims = session["id_token"].(map[string]interface{}) + } + + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "redirect_to": "http://rp/cb", + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + + client := &http.Client{Transport: transport} + h := &AuthHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: client, + }, + KratosAdmin: new(MockKratosAdminService), + } + h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-hanmac").Return(&service.KratosIdentity{ + ID: "user-hanmac", + Traits: map[string]interface{}{ + "email": "hanmac-user@example.com", + "name": "한맥 사용자", + "additionalAppointments": []interface{}{ + map[string]interface{}{ + "tenantId": deptID, + "isPrimary": true, + "isOwner": true, + "grade": "책임", + "jobTitle": "기술기획", + "position": "팀장", + }, + map[string]interface{}{ + "tenantId": secondDeptID, + "isPrimary": false, + "isOwner": false, + "grade": "선임", + "jobTitle": "품질관리", + "position": "파트원", + }, + }, + }, + }, nil) + + mockTenantSvc := new(MockTenantService) + mockTenantSvc.On("ListJoinedTenants", mock.Anything, "user-hanmac").Return([]domain.Tenant{}, nil) + mockTenantSvc.On("GetTenant", mock.Anything, deptID).Return(&domain.Tenant{ + ID: deptID, + Slug: "tech-planning", + Name: "기술기획팀", + Type: domain.TenantTypeUserGroup, + ParentID: &companyID, + }, nil) + mockTenantSvc.On("GetTenant", mock.Anything, secondDeptID).Return(&domain.Tenant{ + ID: secondDeptID, + Slug: "quality", + Name: "품질관리팀", + Type: domain.TenantTypeUserGroup, + ParentID: &companyID, + }, nil) + mockTenantSvc.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{ + ID: companyID, + Slug: "hanmac", + Name: "한맥기술", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + }, nil) + mockTenantSvc.On("GetTenant", mock.Anything, rootID).Return(&domain.Tenant{ + ID: rootID, + Slug: "hanmac-family", + Name: "한맥가족", + Type: domain.TenantTypeCompanyGroup, + }, nil) + h.TenantService = mockTenantSvc + + app := fiber.New() + app.Post("/api/v1/auth/consent/accept", h.AcceptConsentRequest) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "consent_challenge": "challenge-hanmac-tenant-claim", + "grant_scope": []string{"openid", "profile", "tenant"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/consent/accept", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + assert.NotNil(t, capturedClaims) + assert.Equal(t, []interface{}{deptID}, capturedClaims["lead_tenants"]) + assert.ElementsMatch(t, []interface{}{deptID, secondDeptID}, capturedClaims["joined_tenants"]) + tenants := capturedClaims["tenants"].(map[string]interface{}) + dept := tenants[deptID].(map[string]interface{}) + assert.Equal(t, true, dept["lead"]) + assert.Equal(t, true, dept["representative"]) + assert.Equal(t, "책임", dept["grade"]) + assert.Equal(t, "기술기획", dept["jobTitle"]) + assert.Equal(t, "팀장", dept["position"]) + assert.Equal(t, companyID, dept["parentTenantId"]) + assert.NotContains(t, dept, "parentTenant") + + ancestors := dept["ancestors"].([]interface{}) + assert.Len(t, ancestors, 2) + companyAncestor := ancestors[0].(map[string]interface{}) + assert.Equal(t, companyID, companyAncestor["id"]) + assert.Equal(t, "hanmac", companyAncestor["slug"]) + assert.Equal(t, rootID, companyAncestor["parentTenantId"]) + assert.NotContains(t, companyAncestor, "parentTenant") + rootAncestor := ancestors[1].(map[string]interface{}) + assert.Equal(t, rootID, rootAncestor["id"]) + assert.Equal(t, "hanmac-family", rootAncestor["slug"]) + assert.Contains(t, rootAncestor, "parentTenantId") + assert.Nil(t, rootAncestor["parentTenantId"]) + assert.NotContains(t, rootAncestor, "parentTenant") + + secondDept := tenants[secondDeptID].(map[string]interface{}) + assert.Equal(t, false, secondDept["lead"]) + assert.Equal(t, false, secondDept["representative"]) + assert.Equal(t, "선임", secondDept["grade"]) + assert.Equal(t, "품질관리", secondDept["jobTitle"]) + assert.Equal(t, "파트원", secondDept["position"]) + assert.Equal(t, companyID, secondDept["parentTenantId"]) +} + +func TestWithHanmacFamilyTenantClaims_DefaultClaimsOnlyWithoutTenantScope(t *testing.T) { + deptID := "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" + secondDeptID := "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + companyID := "01970f08-91da-7286-bd19-882fb98d1f2c" + rootID := "01970f07-4f01-7d9a-a71e-b53ad508f345" + + mockTenantSvc := new(MockTenantService) + mockTenantSvc.On("GetTenant", mock.Anything, deptID).Return(&domain.Tenant{ + ID: deptID, + Slug: "tech-planning", + Name: "기술기획팀", + Type: domain.TenantTypeUserGroup, + ParentID: &companyID, + }, nil) + mockTenantSvc.On("GetTenant", mock.Anything, secondDeptID).Return(&domain.Tenant{ + ID: secondDeptID, + Slug: "quality", + Name: "품질관리팀", + Type: domain.TenantTypeUserGroup, + ParentID: &companyID, + }, nil) + mockTenantSvc.On("GetTenant", mock.Anything, companyID).Return(&domain.Tenant{ + ID: companyID, + Slug: "hanmac", + Name: "한맥기술", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + }, nil) + mockTenantSvc.On("GetTenant", mock.Anything, rootID).Return(&domain.Tenant{ + ID: rootID, + Slug: "hanmac-family", + Name: "한맥가족", + Type: domain.TenantTypeCompanyGroup, + }, nil) + + h := &AuthHandler{TenantService: mockTenantSvc} + claims := map[string]any{"tenant_id": deptID} + traits := map[string]any{ + "additionalAppointments": []any{ + map[string]any{ + "tenantId": deptID, + "isPrimary": true, + "isOwner": true, + "grade": "책임", + }, + map[string]any{ + "tenantId": secondDeptID, + "grade": "선임", + }, + }, + } + + claims = h.withHanmacFamilyTenantClaims(context.Background(), claims, traits, []string{"openid", "profile"}) + + assert.Equal(t, deptID, claims["tenant_id"]) + assert.ElementsMatch(t, []string{deptID, secondDeptID}, claims["joined_tenants"]) + assert.NotContains(t, claims, "tenants") + assert.NotContains(t, claims, "lead_tenants") +} + func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) { var capturedClaims map[string]interface{} @@ -186,7 +518,7 @@ func TestAcceptConsentRequest_IncludesRPProfileClaims(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-rp-profile" { return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "challenge": "challenge-rp-profile", - "requested_scope": []string{"openid", "profile"}, + "requested_scope": []string{"openid", "profile", "tenant"}, "subject": "user-123", "client": map[string]interface{}{ "client_id": "client-app", @@ -284,7 +616,7 @@ func TestGetConsentRequest_Skip_DynamicClaims(t *testing.T) { if r.URL.Path == "/oauth2/auth/requests/consent" && r.URL.Query().Get("consent_challenge") == "challenge-skip-dynamic" { return httpJSONAny(r, http.StatusOK, map[string]interface{}{ "challenge": "challenge-skip-dynamic", - "requested_scope": []string{"openid", "profile"}, + "requested_scope": []string{"openid", "profile", "tenant"}, "skip": true, "subject": "user-456", "client": map[string]interface{}{ diff --git a/backend/internal/handler/tenant_assignment_policy.go b/backend/internal/handler/tenant_assignment_policy.go new file mode 100644 index 00000000..ba8efff1 --- /dev/null +++ b/backend/internal/handler/tenant_assignment_policy.go @@ -0,0 +1,155 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +func representativeTenantIDFromTraits(traits map[string]any) string { + if value := tenantClaimString(traits, "tenant_id"); value != "" { + return value + } + if value := tenantClaimString(traits, "primaryTenantId"); value != "" { + return value + } + if metadata, ok := traits["metadata"].(map[string]any); ok { + if value := tenantClaimString(metadata, "primaryTenantId"); value != "" { + return value + } + } + + appointments := tenantAssignmentAppointmentsFromTraits(traits) + for _, appointment := range appointments { + if tenantAssignmentBool(appointment, "isPrimary", "primary", "representative", "isRepresentative") { + if value := tenantAssignmentTenantID(appointment); value != "" { + return value + } + } + } + for _, appointment := range appointments { + if value := tenantAssignmentTenantID(appointment); value != "" { + return value + } + } + + for _, tenantID := range tenantNamespaceIDsFromTraits(traits) { + return tenantID + } + return "" +} + +func joinedTenantIDsFromTraits(traits map[string]any, representativeTenantID string) []string { + values := make([]string, 0) + if representativeTenantID != "" { + values = append(values, representativeTenantID) + } + if value := tenantClaimString(traits, "tenant_id"); value != "" { + values = append(values, value) + } + for _, appointment := range tenantAssignmentAppointmentsFromTraits(traits) { + if value := tenantAssignmentTenantID(appointment); value != "" { + values = append(values, value) + } + } + values = append(values, tenantNamespaceIDsFromTraits(traits)...) + return uniqueSortedStrings(values) +} + +func tenantAssignmentAppointmentsFromTraits(traits map[string]any) []map[string]any { + raw := rawAdditionalAppointments(traits) + switch values := raw.(type) { + case []any: + appointments := make([]map[string]any, 0, len(values)) + for _, item := range values { + if appointment, ok := item.(map[string]any); ok { + appointments = append(appointments, appointment) + } + } + return appointments + case []map[string]any: + return values + default: + return nil + } +} + +func tenantAssignmentTenantID(appointment map[string]any) string { + for _, key := range []string{"tenantId", "tenant_id"} { + if value := tenantClaimString(appointment, key); value != "" { + return value + } + } + return "" +} + +func tenantAssignmentBool(values map[string]any, keys ...string) bool { + for _, key := range keys { + raw, ok := values[key] + if !ok || raw == nil { + continue + } + switch value := raw.(type) { + case bool: + if value { + return true + } + case string: + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "true" || normalized == "1" || normalized == "yes" { + return true + } + } + } + return false +} + +func tenantNamespaceIDsFromTraits(traits map[string]any) []string { + if traits == nil { + return nil + } + ids := make([]string, 0) + for key, value := range traits { + if key == "" || key == "metadata" { + continue + } + switch value.(type) { + case map[string]any: + ids = append(ids, key) + } + } + return uniqueSortedStrings(ids) +} + +func createPersonalTenantForUser(ctx context.Context, tenantService service.TenantService, email string) (*domain.Tenant, error) { + if tenantService == nil { + return nil, errors.New("tenant service unavailable") + } + normalizedEmail := strings.ToLower(strings.TrimSpace(email)) + if normalizedEmail == "" { + normalizedEmail = "user" + } + slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "") + tenant, err := tenantService.RegisterTenant( + ctx, + fmt.Sprintf("Personal - %s", normalizedEmail), + slug, + domain.TenantTypePersonal, + "Automatically provisioned personal tenant", + nil, + nil, + "", + ) + if err != nil { + return nil, err + } + if tenant == nil { + return nil, errors.New("personal tenant not created") + } + return tenant, nil +} diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index ea061876..bf07f0ce 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -114,6 +114,60 @@ type tenantCSVRecord struct { OrgUnitType string } +type orgContextTenant struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Slug string `json:"slug"` + ParentID *string `json:"parentId"` + Status string `json:"status"` + Description string `json:"description"` + Domains []string `json:"domains,omitempty"` + MemberCount int64 `json:"memberCount"` + Visibility string `json:"visibility"` + OrgUnitType string `json:"orgUnitType,omitempty"` + Config domain.JSONMap `json:"config,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type orgContextUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Status string `json:"status"` + TenantIDs []string `json:"tenantIds"` + TenantSlugs []string `json:"tenantSlugs"` + Department string `json:"department,omitempty"` + Grade string `json:"grade,omitempty"` + Position string `json:"position,omitempty"` + JobTitle string `json:"jobTitle,omitempty"` + Metadata domain.JSONMap `json:"metadata,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type orgContextTreeNode struct { + orgContextTenant + DirectUserIDs []string `json:"directUserIds"` + Children []orgContextTreeNode `json:"children"` +} + +type orgContextScope struct { + TenantID string `json:"tenantId"` + TenantSlug string `json:"tenantSlug"` +} + +type orgContextResponse struct { + SchemaVersion string `json:"schemaVersion"` + IssuedAt string `json:"issuedAt"` + Scope orgContextScope `json:"scope"` + Tree *orgContextTreeNode `json:"tree"` + Tenants []orgContextTenant `json:"tenants"` + Users []orgContextUser `json:"users"` +} + func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { var req struct { Name string `json:"name"` @@ -271,10 +325,12 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { } func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { - tenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") + parentID := strings.TrimSpace(c.Query("parentId")) + allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + tenants := filterTenantCSVDescendants(allTenants, parentID) var buf bytes.Buffer writer := csv.NewWriter(&buf) @@ -286,8 +342,8 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { } else if err := writer.Write([]string{"name", "type", "parent_tenant_slug", "slug", "memo", "email_domain", "visibility", "org_unit_type"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - slugByID := make(map[string]string, len(tenants)) - for _, tenant := range tenants { + slugByID := make(map[string]string, len(allTenants)) + for _, tenant := range allTenants { slugByID[tenant.ID] = tenant.Slug } for _, tenant := range tenants { @@ -343,6 +399,41 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error { return c.Send(buf.Bytes()) } +func filterTenantCSVDescendants(tenants []domain.Tenant, parentID string) []domain.Tenant { + parentID = strings.TrimSpace(parentID) + if parentID == "" { + return tenants + } + + descendantIDs := map[string]bool{} + frontier := map[string]bool{parentID: true} + for len(frontier) > 0 { + next := map[string]bool{} + for _, tenant := range tenants { + if tenant.ParentID == nil { + continue + } + if !frontier[strings.TrimSpace(*tenant.ParentID)] { + continue + } + if descendantIDs[tenant.ID] { + continue + } + descendantIDs[tenant.ID] = true + next[tenant.ID] = true + } + frontier = next + } + + filtered := make([]domain.Tenant, 0, len(descendantIDs)) + for _, tenant := range tenants { + if descendantIDs[tenant.ID] { + filtered = append(filtered, tenant) + } + } + return filtered +} + func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error { reader, err := tenantCSVReaderFromRequest(c) if err != nil { @@ -1818,6 +1909,272 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { } } +func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error { + if c.Locals("apiKeyName") == nil { + return errorJSON(c, fiber.StatusUnauthorized, "api key authentication is required") + } + if h.Service == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "tenant service is not configured") + } + + allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + rootSlug := strings.TrimSpace(c.Query("tenantSlug")) + if rootSlug == "" { + rootSlug = "hanmac-family" + } + + root, ok := findOrgContextTenantBySlug(allTenants, rootSlug) + if !ok { + return errorJSON(c, fiber.StatusNotFound, "tenant slug not found") + } + + scopedTenants := filterOrgContextSubtree(allTenants, root.ID) + contextTenants := make([]orgContextTenant, 0, len(scopedTenants)) + tenantIDs := make([]string, 0, len(scopedTenants)) + tenantSlugs := make([]string, 0, len(scopedTenants)) + tenantByID := make(map[string]orgContextTenant, len(scopedTenants)) + tenantBySlug := make(map[string]orgContextTenant, len(scopedTenants)) + for _, tenant := range scopedTenants { + summary := mapOrgContextTenant(tenant) + contextTenants = append(contextTenants, summary) + tenantIDs = append(tenantIDs, tenant.ID) + tenantSlugs = append(tenantSlugs, tenant.Slug) + tenantByID[tenant.ID] = summary + tenantBySlug[strings.ToLower(tenant.Slug)] = summary + } + + includeUsers := !strings.EqualFold(strings.TrimSpace(c.Query("includeUsers")), "false") + contextUsers := []orgContextUser{} + if includeUsers { + if h.UserRepo == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured") + } + contextUsers, err = h.loadOrgContextUsers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + } + + directUserIDsByTenantID := make(map[string][]string) + for _, user := range contextUsers { + for _, tenantID := range user.TenantIDs { + directUserIDsByTenantID[tenantID] = append(directUserIDsByTenantID[tenantID], user.ID) + } + } + + tree := buildOrgContextTree(root.ID, scopedTenants, tenantByID, directUserIDsByTenantID) + return c.JSON(orgContextResponse{ + SchemaVersion: "baron.org-context.v1", + IssuedAt: time.Now().UTC().Format(time.RFC3339), + Scope: orgContextScope{ + TenantID: root.ID, + TenantSlug: root.Slug, + }, + Tree: tree, + Tenants: contextTenants, + Users: contextUsers, + }) +} + +func (h *TenantHandler) loadOrgContextUsers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant) ([]orgContextUser, error) { + usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs) + if err != nil { + return nil, err + } + usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs) + if err != nil { + return nil, err + } + + seen := make(map[string]bool) + contextUsers := make([]orgContextUser, 0, len(usersByID)+len(usersBySlug)) + for _, user := range append(usersByID, usersBySlug...) { + if seen[user.ID] || user.Status != domain.UserStatusActive { + continue + } + mapped, ok := mapOrgContextUser(user, tenantByID, tenantBySlug) + if !ok { + continue + } + seen[user.ID] = true + contextUsers = append(contextUsers, mapped) + } + return contextUsers, nil +} + +func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) { + normalized := strings.ToLower(strings.TrimSpace(slug)) + for _, tenant := range tenants { + if strings.ToLower(tenant.Slug) == normalized && isOrgContextTenantType(tenant) { + return tenant, true + } + } + return domain.Tenant{}, false +} + +func isOrgContextTenantType(tenant domain.Tenant) bool { + switch strings.ToUpper(tenant.Type) { + case domain.TenantTypeCompanyGroup, domain.TenantTypeCompany, domain.TenantTypeOrganization, domain.TenantTypeUserGroup: + return true + default: + return false + } +} + +func filterOrgContextSubtree(tenants []domain.Tenant, rootID string) []domain.Tenant { + descendantIDs := map[string]bool{rootID: true} + frontier := map[string]bool{rootID: true} + for len(frontier) > 0 { + next := map[string]bool{} + for _, tenant := range tenants { + if tenant.ParentID == nil || !frontier[*tenant.ParentID] || descendantIDs[tenant.ID] { + continue + } + descendantIDs[tenant.ID] = true + next[tenant.ID] = true + } + frontier = next + } + + excludedIDs := map[string]bool{} + for _, tenant := range tenants { + if descendantIDs[tenant.ID] && tenantVisibility(tenant.Config) == "private" { + excludedIDs[tenant.ID] = true + } + } + changed := true + for changed { + changed = false + for _, tenant := range tenants { + if tenant.ParentID == nil || !descendantIDs[tenant.ID] || excludedIDs[tenant.ID] { + continue + } + if excludedIDs[*tenant.ParentID] { + excludedIDs[tenant.ID] = true + changed = true + } + } + } + + filtered := make([]domain.Tenant, 0, len(descendantIDs)) + for _, tenant := range tenants { + if descendantIDs[tenant.ID] && !excludedIDs[tenant.ID] && isOrgContextTenantType(tenant) { + filtered = append(filtered, tenant) + } + } + return filtered +} + +func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant { + domains := make([]string, 0, len(tenant.Domains)) + for _, domain := range tenant.Domains { + domains = append(domains, domain.Domain) + } + visibility, orgUnitType := tenantCSVOrgConfigValues(tenant.Config) + return orgContextTenant{ + ID: tenant.ID, + Type: tenant.Type, + Name: tenant.Name, + Slug: tenant.Slug, + ParentID: tenant.ParentID, + Status: tenant.Status, + Description: tenant.Description, + Domains: domains, + Visibility: visibility, + OrgUnitType: orgUnitType, + Config: tenant.Config, + CreatedAt: tenant.CreatedAt.Format(time.RFC3339), + UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339), + } +} + +func mapOrgContextUser(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant) (orgContextUser, bool) { + matchedTenants := make([]orgContextTenant, 0, 2) + seenTenants := map[string]bool{} + addTenant := func(tenant orgContextTenant, ok bool) { + if !ok || seenTenants[tenant.ID] { + return + } + seenTenants[tenant.ID] = true + matchedTenants = append(matchedTenants, tenant) + } + + if user.TenantID != nil { + addTenant(tenantByID[*user.TenantID], tenantByID[*user.TenantID].ID != "") + } + if user.Tenant != nil { + addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "") + addTenant(tenantBySlug[strings.ToLower(user.Tenant.Slug)], tenantBySlug[strings.ToLower(user.Tenant.Slug)].ID != "") + } + if user.CompanyCode != "" { + addTenant(tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))], tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))].ID != "") + } + for _, companyCode := range user.CompanyCodes { + addTenant(tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))], tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))].ID != "") + } + if len(matchedTenants) == 0 { + return orgContextUser{}, false + } + + tenantIDs := make([]string, 0, len(matchedTenants)) + tenantSlugs := make([]string, 0, len(matchedTenants)) + for _, tenant := range matchedTenants { + tenantIDs = append(tenantIDs, tenant.ID) + tenantSlugs = append(tenantSlugs, tenant.Slug) + } + return orgContextUser{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + Role: user.Role, + Status: user.Status, + TenantIDs: tenantIDs, + TenantSlugs: tenantSlugs, + Department: user.Department, + Grade: user.Grade, + Position: user.Position, + JobTitle: user.JobTitle, + Metadata: user.Metadata, + CreatedAt: user.CreatedAt.Format(time.RFC3339), + UpdatedAt: user.UpdatedAt.Format(time.RFC3339), + }, true +} + +func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant, directUserIDsByTenantID map[string][]string) *orgContextTreeNode { + childrenByParentID := make(map[string][]domain.Tenant) + for _, tenant := range tenants { + if tenant.ParentID == nil { + continue + } + childrenByParentID[*tenant.ParentID] = append(childrenByParentID[*tenant.ParentID], tenant) + } + + var build func(tenantID string) *orgContextTreeNode + build = func(tenantID string) *orgContextTreeNode { + tenant, ok := tenantByID[tenantID] + if !ok { + return nil + } + node := &orgContextTreeNode{ + orgContextTenant: tenant, + DirectUserIDs: directUserIDsByTenantID[tenantID], + Children: []orgContextTreeNode{}, + } + for _, child := range childrenByParentID[tenantID] { + childNode := build(child.ID) + if childNode != nil { + node.Children = append(node.Children, *childNode) + } + } + return node + } + return build(rootID) +} + func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { counts := make(map[string]int64, len(tenants)) for _, tenant := range tenants { diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 69989e64..8409c91b 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -13,6 +13,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" @@ -213,6 +214,13 @@ func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncE return args.Error(0) } +func toJSONString(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + require.NoError(t, err) + return string(raw) +} + func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -360,6 +368,121 @@ func TestTenantHandler_ListTenants(t *testing.T) { } } +func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockUsers := new(MockUserRepoForHandler) + h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers} + + app.Use(func(c *fiber.Ctx) error { + c.Locals("apiKeyName", "orgfront-ssot-client") + return c.Next() + }) + app.Get("/org-context", h.GetOrgContext) + + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + parent := func(id string) *string { return &id } + tenants := []domain.Tenant{ + {ID: "root-other", Type: domain.TenantTypeCompanyGroup, Name: "다른그룹", Slug: "other-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, Config: domain.JSONMap{"orgUnitType": "실"}, CreatedAt: now, UpdatedAt: now}, + {ID: "team-sso", Type: domain.TenantTypeUserGroup, ParentID: parent("dept-platform"), Name: "SSO팀", Slug: "sso", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "비공개", Slug: "private-team", Status: domain.TenantStatusActive, Config: domain.JSONMap{"visibility": "private"}, CreatedAt: now, UpdatedAt: now}, + } + usersByTenantID := []domain.User{ + {ID: "user-platform-lead", Email: "lead@example.com", Name: "플랫폼 리드", Status: domain.UserStatusActive, TenantID: parent("dept-platform"), CompanyCode: "platform", Grade: "책임", Position: "실장", CreatedAt: now, UpdatedAt: now}, + } + usersBySlug := []domain.User{ + {ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now}, + } + + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil) + mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil) + + req := httptest.NewRequest(http.MethodGet, "/org-context", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Equal(t, "baron.org-context.v1", got["schemaVersion"]) + + scope := got["scope"].(map[string]any) + require.Equal(t, "group-hanmac-family", scope["tenantId"]) + require.Equal(t, "hanmac-family", scope["tenantSlug"]) + + tenantsPayload := got["tenants"].([]any) + require.Len(t, tenantsPayload, 4) + require.Equal(t, "group-hanmac-family", tenantsPayload[0].(map[string]any)["id"]) + require.Equal(t, "company-hanmac", tenantsPayload[1].(map[string]any)["id"]) + require.Equal(t, "dept-platform", tenantsPayload[2].(map[string]any)["id"]) + require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"]) + + usersPayload := got["users"].([]any) + require.Len(t, usersPayload, 2) + require.Equal(t, "user-platform-lead", usersPayload[0].(map[string]any)["id"]) + require.Equal(t, []any{"dept-platform"}, usersPayload[0].(map[string]any)["tenantIds"]) + require.Equal(t, "user-sso-member", usersPayload[1].(map[string]any)["id"]) + + tree := got["tree"].(map[string]any) + require.Equal(t, "group-hanmac-family", tree["id"]) + require.NotContains(t, toJSONString(t, got), "private-team") + require.NotContains(t, toJSONString(t, got), "root-other") +} + +func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockUsers := new(MockUserRepoForHandler) + h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers} + + app.Use(func(c *fiber.Ctx) error { + c.Locals("apiKeyName", "orgfront-ssot-client") + return c.Next() + }) + app.Get("/org-context", h.GetOrgContext) + + now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC) + parent := func(id string) *string { return &id } + tenants := []domain.Tenant{ + {ID: "group-hanmac-family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "company-hanmac", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "dept-platform", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + {ID: "company-other", Type: domain.TenantTypeCompany, ParentID: parent("group-hanmac-family"), Name: "다른회사", Slug: "other", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now}, + } + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil) + mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil) + + req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var got map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + scope := got["scope"].(map[string]any) + require.Equal(t, "company-hanmac", scope["tenantId"]) + require.Equal(t, "hanmac", scope["tenantSlug"]) + + require.Contains(t, toJSONString(t, got), "dept-platform") + require.NotContains(t, toJSONString(t, got), "company-other") +} + +func TestTenantHandler_GetOrgContextJSONRequiresApiKey(t *testing.T) { + app := fiber.New() + h := &TenantHandler{} + app.Get("/org-context", h.GetOrgContext) + + req := httptest.NewRequest(http.MethodGet, "/org-context", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -518,6 +641,62 @@ func TestTenantHandler_ExportTenantsCSV_OmitsIDsAndUsesParentSlug(t *testing.T) mockSvc.AssertExpectations(t) } +func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + h := &TenantHandler{Service: mockSvc} + + app.Get("/tenants/export", h.ExportTenantsCSV) + + parentID := "11111111-2222-4333-8444-555555555555" + childID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + grandchildID := "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" + unrelatedID := "cccccccc-dddd-4eee-8fff-111111111111" + tenants := []domain.Tenant{ + { + ID: parentID, + Name: "Parent Org", + Type: domain.TenantTypeCompany, + Slug: "parent-org", + }, + { + ID: childID, + Name: "Child Org", + Type: domain.TenantTypeOrganization, + ParentID: &parentID, + Slug: "child-org", + }, + { + ID: grandchildID, + Name: "Leaf Team", + Type: domain.TenantTypeUserGroup, + ParentID: &childID, + Slug: "leaf-team", + }, + { + ID: unrelatedID, + Name: "Unrelated Org", + Type: domain.TenantTypeOrganization, + Slug: "unrelated-org", + }, + } + + mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil) + + req := httptest.NewRequest("GET", "/tenants/export?includeIds=true&parentId="+parentID, nil) + resp, _ := app.Test(req) + body, _ := io.ReadAll(resp.Body) + text := string(body) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Contains(t, text, "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type") + assert.Contains(t, text, childID+",Child Org,ORGANIZATION,"+parentID+",parent-org,child-org,") + assert.Contains(t, text, grandchildID+",Leaf Team,USER_GROUP,"+childID+",child-org,leaf-team,") + assert.NotContains(t, text, unrelatedID) + assert.NotContains(t, text, "Parent Org") + mockSvc.AssertExpectations(t) +} + func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index ff9440f8..70efcb14 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -570,9 +570,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { // [Resolve TenantID and Custom Login IDs before Kratos creation] var tenantID string + requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments) if req.CompanyCode == "" && h.TenantService != nil { - if primaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments); primaryTenantID != "" { - if tenant, err := h.TenantService.GetTenant(c.Context(), primaryTenantID); err == nil && tenant != nil { + if requestedPrimaryTenantID != "" { + if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil { tenantID = tenant.ID req.CompanyCode = tenant.Slug } @@ -583,6 +584,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { tenantID = tenant.ID } } + if tenantID == "" { + if req.CompanyCode != "" || requestedPrimaryTenantID != "" { + return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment") + } + tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email) + if err != nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") + } + tenantID = tenant.ID + req.CompanyCode = tenant.Slug + } // Collect and sync all custom login IDs based on tenant schemas loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") @@ -857,6 +869,14 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { return tItem, true } + createPersonalTenantItem := func(email string) (tenantCacheItem, error) { + tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email) + if err != nil { + return tenantCacheItem{}, err + } + return cacheTenantItem(buildTenantCacheItem(tenant)), nil + } + for _, item := range req.Users { email := strings.TrimSpace(item.Email) name := strings.TrimSpace(item.Name) @@ -898,8 +918,12 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } } if tenantSlug == "" { - results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant assignment is required"}) - continue + tItem, err = createPersonalTenantItem(email) + if err != nil { + results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"}) + continue + } + tenantSlug = tItem.Slug } } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 6c136b33..17e5f20d 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -176,6 +176,14 @@ func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, return args.Get(0).(*domain.Tenant), args.Error(1) } +func (m *MockTenantServiceForUser) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + // --- Tests --- func TestUserHandler_ExportUsersCSV_UsesTenantSlugAliasAndOmitsRole(t *testing.T) { @@ -1411,6 +1419,85 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test mockOry.AssertExpectations(t) } +func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + app.Post("/users", h.CreateUser) + + personalTenantID := "01970f0d-9666-7548-963d-2890351f03dd" + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) + mockTenant.On( + "RegisterTenant", + mock.Anything, + "Personal - personal-user@example.com", + mock.MatchedBy(func(slug string) bool { return strings.HasPrefix(slug, "personal-") }), + domain.TenantTypePersonal, + "Automatically provisioned personal tenant", + []string(nil), + (*string)(nil), + "", + ).Return(&domain.Tenant{ + ID: personalTenantID, + Slug: "personal-01970f0d96667548963d2890351f03dd", + Name: "Personal - personal-user@example.com", + Type: domain.TenantTypePersonal, + Status: domain.TenantStatusActive, + Config: domain.JSONMap{}, + }, nil).Once() + mockTenant.On("GetTenant", mock.Anything, personalTenantID).Return(&domain.Tenant{ + ID: personalTenantID, + Slug: "personal-01970f0d96667548963d2890351f03dd", + Name: "Personal - personal-user@example.com", + Type: domain.TenantTypePersonal, + Status: domain.TenantStatusActive, + Config: domain.JSONMap{}, + }, nil).Once() + mockTenant.On("GetTenantBySlug", mock.Anything, "personal-01970f0d96667548963d2890351f03dd").Return(&domain.Tenant{ + ID: personalTenantID, + Slug: "personal-01970f0d96667548963d2890351f03dd", + Name: "Personal - personal-user@example.com", + Type: domain.TenantTypePersonal, + Status: domain.TenantStatusActive, + Config: domain.JSONMap{}, + }, nil).Once() + mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { + return user.Email == "personal-user@example.com" && + user.Attributes["tenant_id"] == personalTenantID && + user.Attributes["companyCode"] == "personal-01970f0d96667548963d2890351f03dd" + }), mock.Anything).Return("u-personal", nil).Once() + mockKratos.On("GetIdentity", mock.Anything, "u-personal").Return(&service.KratosIdentity{ + ID: "u-personal", + Traits: map[string]interface{}{ + "email": "personal-user@example.com", + "name": "Personal User", + "companyCode": "personal-01970f0d96667548963d2890351f03dd", + "tenant_id": personalTenantID, + }, + State: "active", + }, nil).Once() + payload := map[string]interface{}{ + "email": "personal-user@example.com", + "name": "Personal User", + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/users", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + mockTenant.AssertExpectations(t) + mockOry.AssertExpectations(t) + mockKratos.AssertExpectations(t) +} + func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) { handler := &UserHandler{} identity := service.KratosIdentity{ diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go index 555e994c..cda5234d 100644 --- a/backend/internal/middleware/api_key_auth.go +++ b/backend/internal/middleware/api_key_auth.go @@ -84,6 +84,10 @@ func validateScope(method, path string, rawScopes string) bool { scopeMap[s] = true } + if strings.Contains(path, "/integrations/org-context") { + return method == fiber.MethodGet && scopeMap["org-context:read"] + } + // 1. 감사 로그 관련 (audit:*) if strings.Contains(path, "/admin/audit") || strings.Contains(path, "/v1/audit") { if method == fiber.MethodGet { diff --git a/backend/internal/middleware/api_key_auth_test.go b/backend/internal/middleware/api_key_auth_test.go new file mode 100644 index 00000000..fb1efbde --- /dev/null +++ b/backend/internal/middleware/api_key_auth_test.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/require" +) + +func TestValidateScope_AllowsOrgContextReadOnly(t *testing.T) { + require.True(t, validateScope(fiber.MethodGet, "/api/v1/integrations/org-context", "org-context:read")) + require.False(t, validateScope(fiber.MethodPost, "/api/v1/integrations/org-context", "org-context:read")) + require.False(t, validateScope(fiber.MethodGet, "/api/v1/integrations/org-context", "tenant:read")) + require.False(t, validateScope(fiber.MethodGet, "/api/v1/orgfront/org-context", "org-context:read")) +} diff --git a/devfront/src/features/auth/LoginPage.tsx b/devfront/src/features/auth/LoginPage.tsx index 212eabf7..293f8eaa 100644 --- a/devfront/src/features/auth/LoginPage.tsx +++ b/devfront/src/features/auth/LoginPage.tsx @@ -1,5 +1,5 @@ -import { ExternalLink, LogIn, ShieldHalf } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate } from "react-router-dom"; import { useSearchParams } from "react-router-dom"; @@ -11,14 +11,30 @@ import { CardHeader, CardTitle, } from "../../components/ui/card"; +import { canStartBrowserPkceLogin } from "../../lib/authConfig"; + +const insecurePkceMessage = + "이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요."; function LoginPage() { const auth = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const autoStartedRef = useRef(false); + const [loginError, setLoginError] = useState(null); const returnTo = searchParams.get("returnTo") || "/clients"; const shouldAutoLogin = searchParams.get("auto") === "1"; + const authErrorMessage = useMemo(() => { + const message = auth.error?.message; + if (!message) { + return null; + } + if (message.includes("Crypto.subtle")) { + return insecurePkceMessage; + } + return message; + }, [auth.error?.message]); + const visibleLoginError = loginError || authErrorMessage; useEffect(() => { if (auth.isAuthenticated) { @@ -33,6 +49,10 @@ function LoginPage() { if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) { return; } + if (!canStartBrowserPkceLogin()) { + setLoginError(insecurePkceMessage); + return; + } autoStartedRef.current = true; void auth.signinRedirect({ @@ -44,6 +64,11 @@ function LoginPage() { const handleSSOLogin = async () => { try { + setLoginError(null); + if (!canStartBrowserPkceLogin()) { + setLoginError(insecurePkceMessage); + return; + } await auth.signinRedirect({ state: { returnTo: "/clients", @@ -99,6 +124,16 @@ function LoginPage() { )} + {visibleLoginError ? ( +
+ + {visibleLoginError} +
+ ) : null} +

개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
diff --git a/devfront/src/lib/authConfig.test.ts b/devfront/src/lib/authConfig.test.ts index 974e5767..e5abdb1a 100644 --- a/devfront/src/lib/authConfig.test.ts +++ b/devfront/src/lib/authConfig.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEVFRONT_AUTH_CALLBACK_PATH, buildDevFrontAuthRedirectUris, + canStartBrowserPkceLogin, resolveDevFrontPublicOrigin, } from "./authConfig"; @@ -26,4 +27,69 @@ describe("devfront auth config", () => { it("keeps the callback path aligned with the registered redirect path", () => { expect(DEVFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback"); }); + + it("blocks browser PKCE login in an insecure context", () => { + expect( + canStartBrowserPkceLogin({ + isSecureContext: false, + origin: "http://172.16.9.189:5174", + cryptoSubtleAvailable: false, + }), + ).toBe(false); + expect( + canStartBrowserPkceLogin({ + isSecureContext: true, + origin: "http://172.16.9.189:5174", + cryptoSubtleAvailable: true, + }), + ).toBe(true); + }); + + it("allows host.docker.internal when WebCrypto is enabled by the browser", () => { + expect( + canStartBrowserPkceLogin({ + isSecureContext: false, + origin: "http://host.docker.internal:5000", + cryptoSubtleAvailable: true, + }), + ).toBe(true); + expect( + canStartBrowserPkceLogin({ + isSecureContext: false, + origin: "http://host.docker.internal:5000", + cryptoSubtleAvailable: false, + }), + ).toBe(false); + }); + + it("allows private network IPv4 origins when WebCrypto is enabled by the browser", () => { + for (const origin of [ + "http://10.0.0.10:5000", + "http://172.16.9.189:5000", + "http://172.31.255.255:5000", + "http://192.168.0.20:5000", + ]) { + expect( + canStartBrowserPkceLogin({ + isSecureContext: false, + origin, + cryptoSubtleAvailable: true, + }), + ).toBe(true); + } + + for (const origin of [ + "http://172.15.255.255:5000", + "http://172.32.0.1:5000", + "http://8.8.8.8:5000", + ]) { + expect( + canStartBrowserPkceLogin({ + isSecureContext: false, + origin, + cryptoSubtleAvailable: true, + }), + ).toBe(false); + } + }); }); diff --git a/devfront/src/lib/authConfig.ts b/devfront/src/lib/authConfig.ts index 1887431e..5fdc9b61 100644 --- a/devfront/src/lib/authConfig.ts +++ b/devfront/src/lib/authConfig.ts @@ -31,3 +31,54 @@ export function buildDevFrontAuthRedirectUris( popupRedirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`, }; } + +export type BrowserPkceLoginCheck = { + isSecureContext?: boolean; + origin?: string; + cryptoSubtleAvailable?: boolean; +}; + +const devTrustedPkceHosts = new Set([ + "localhost", + "127.0.0.1", + "::1", + "host.docker.internal", +]); + +function isPrivateIPv4(hostname: string) { + const parts = hostname.split(".").map((part) => Number.parseInt(part, 10)); + if ( + parts.length !== 4 || + parts.some((part) => Number.isNaN(part) || part < 0 || part > 255) + ) { + return false; + } + + const [first, second] = parts; + return ( + first === 10 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ); +} + +function isDevTrustedPkceOrigin(origin: string) { + try { + const hostname = new URL(origin).hostname; + return devTrustedPkceHosts.has(hostname) || isPrivateIPv4(hostname); + } catch { + return false; + } +} + +export function canStartBrowserPkceLogin({ + isSecureContext = window.isSecureContext, + origin = window.location.origin, + cryptoSubtleAvailable = Boolean(window.crypto?.subtle), +}: BrowserPkceLoginCheck = {}) { + if (isSecureContext) { + return true; + } + + return isDevTrustedPkceOrigin(origin) && cryptoSubtleAvailable; +} diff --git a/devfront/tests/devfront-login.spec.ts b/devfront/tests/devfront-login.spec.ts new file mode 100644 index 00000000..6cb081fa --- /dev/null +++ b/devfront/tests/devfront-login.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from "@playwright/test"; + +test.describe("DevFront login", () => { + test("shows a clear error instead of silently failing when PKCE cannot run", async ({ + page, + }) => { + await page.addInitScript(() => { + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: false, + }); + }); + + let authorizeRequested = false; + await page.route("**/oidc/.well-known/openid-configuration", async (route) => { + await route.fulfill({ + json: { + issuer: "http://localhost:5000/oidc", + authorization_endpoint: "http://localhost:5000/oidc/oauth2/auth", + token_endpoint: "http://localhost:5000/oidc/oauth2/token", + jwks_uri: "http://localhost:5000/oidc/.well-known/jwks.json", + }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + await page.route("**/oidc/oauth2/auth**", async (route) => { + authorizeRequested = true; + await route.fulfill({ status: 500, body: "unexpected authorize request" }); + }); + + await page.goto("/login"); + await page.getByRole("button", { name: "SSO 계정으로 로그인" }).click(); + + await expect(page.getByRole("alert")).toContainText( + "HTTPS 또는 localhost", + ); + expect(authorizeRequested).toBe(false); + }); +}); diff --git a/docs/custom-field-jsonb-index-policy.md b/docs/custom-field-jsonb-index-policy.md index bb45d84a..32610ed4 100644 --- a/docs/custom-field-jsonb-index-policy.md +++ b/docs/custom-field-jsonb-index-policy.md @@ -99,7 +99,7 @@ Tenant/RP 단위로 묶어서 전달한다. { "tenant_profiles": [ { - "tenant_id": "tenant-uuid", + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", "tenant_slug": "hanmac-family", "fields": { "employeeNo": "E1001" @@ -119,3 +119,123 @@ Tenant/RP 단위로 묶어서 전달한다. - `claimEnabled=true` field만 RP claim 후보로 포함한다. - 긴 JSON 값은 기본적으로 token claim보다 userinfo/profile API 응답에 싣는 방향을 우선한다. + +## 한맥가족 Tenant Claim Projection + +한맥가족(`hanmac-family`) subtree의 tenant claim은 기본 claim과 상세 claim으로 나눈다. 기본 claim은 대표소속 tenant UUID인 `tenant_id`와 전체 소속 목록인 `joined_tenants`이며, RP가 `tenant` claim을 요청하면 tenant별 map 안에 조직 소속 정보를 묶어서 전달한다. 이 정보는 RP가 tenant context를 표시하거나 조직별 기본값을 선택하기 위한 projection이며, 관계형 데이터의 SoT는 PostgreSQL Business DB와 사용자 metadata이다. + +기본 claim 예시는 다음과 같다. + +```json +{ + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "joined_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + ] +} +``` + +Issue #775 구현 결과 기준으로 RP가 `tenant` claim을 요청했을 때 받는 대표 예시는 다음과 같다. + +```json +{ + "email": "hanmac-user@example.com", + "name": "한맥 사용자", + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "joined_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + ], + "lead_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" + ], + "tenants": { + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210": { + "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "slug": "tech-planning", + "name": "기술기획팀", + "type": "USER_GROUP", + "lead": true, + "representative": true, + "isPrimary": true, + "grade": "책임", + "jobTitle": "기술기획", + "position": "팀장", + "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "ancestors": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "slug": "hanmac", + "name": "한맥기술", + "type": "COMPANY", + "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345" + }, + { + "id": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "slug": "hanmac-family", + "name": "한맥가족", + "type": "COMPANY_GROUP", + "parentTenantId": null + } + ] + }, + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661": { + "id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", + "slug": "quality", + "name": "품질관리팀", + "type": "USER_GROUP", + "lead": false, + "representative": false, + "isPrimary": false, + "grade": "선임", + "jobTitle": "품질관리", + "position": "파트원", + "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "ancestors": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "slug": "hanmac", + "name": "한맥기술", + "type": "COMPANY", + "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345" + }, + { + "id": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "slug": "hanmac-family", + "name": "한맥가족", + "type": "COMPANY_GROUP", + "parentTenantId": null + } + ] + } + }, + "profile": { + "emails": [ + "hanmac-user@example.com" + ], + "names": { + "name": "한맥 사용자" + } + } +} +``` + +- 예시의 `id` 값은 UUID 형식의 샘플이며, `slug`와 다르다. +- `tenant_id`와 `joined_tenants`는 기본 claim이다. +- `tenant_id`는 사용자의 대표소속 tenant UUID이다. RP/client context tenant가 없더라도 공백으로 내려가지 않는다. +- `joined_tenants`는 사용자가 claim 상에서 소속된 모든 tenant UUID 목록이다. +- `lead_tenants`는 `tenant` claim 요청 시 포함되며, `lead=true`인 tenant UUID 목록이다. +- `lead`는 tenant lead/조직장 역할을 나타낸다. 입력 metadata에서는 `lead`, `isLead`, `isOwner`, `isManager`를 허용한다. +- `representative`와 `isPrimary`는 대표조직 여부를 나타낸다. 입력 metadata에서는 `representative`, `isPrimary`, `primary`를 허용한다. +- `grade`, `jobTitle`, `position`은 각각 직급, 직무, 직책이다. +- `parentTenantId`는 현재 tenant의 직속 parent tenant UUID이다. 최상위 root는 `null`이다. +- `ancestors`는 직속 상위 tenant부터 `hanmac-family` root까지의 parent chain이다. +- 기본 tenant와 각 ancestor 객체는 `parentTenantId`를 포함하므로, parent edge를 별도 추론 없이 그릴 수 있다. +- 대표소속 결정은 명시적 `tenant_id`, `additionalAppointments`의 `representative/isPrimary/primary=true`, 가장 먼저 등록된 소속 순서로 적용한다. +- 생성 시 소속 tenant가 하나도 없으면 PERSONAL tenant를 자동 생성하고, 해당 tenant를 `tenant_id`와 `joined_tenants`에 포함한다. +- RP/client tenant context는 대표소속 `tenant_id`를 덮어쓰지 않는다. +- tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 projection 항목을 만들 수 있다. +- 멀티 소속이면 기본 claim의 `joined_tenants`에 모든 소속 tenant를 넣는다. `tenant` claim 요청 시에는 `tenants`에도 모든 소속 tenant 상세를 넣고, `lead_tenants`에는 lead tenant만 넣는다. +- token 크기 보호를 위해 전체 조직도나 긴 custom JSON은 claim에 싣지 않고 profile/userinfo API 또는 backend API 응답으로 분리한다. +- RP는 `joined_tenants`로 전체 소속을 읽고, `lead_tenants`로 lead tenant를 빠르게 식별한다. 상세 표시는 `tenants[tenant_id]` 또는 `tenants[joined_tenants[n]]`와 `ancestors`를 조합한다. diff --git a/docs/integrations-org-context-json-api.md b/docs/integrations-org-context-json-api.md new file mode 100644 index 00000000..e1680607 --- /dev/null +++ b/docs/integrations-org-context-json-api.md @@ -0,0 +1,138 @@ +# 조직 Context JSON API 계약 + +## 목적 + +외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성은 Baron SSO backend의 tenant/user projection을 SSOT로 사용하며, iframe 또는 `postMessage` 계약은 사용하지 않는다. + +## 인증 + +API Key 기반 headless 통신만 허용한다. + +```http +X-Baron-Key-ID: +X-Baron-Key-Secret: +``` + +필요 scope는 다음과 같다. + +```text +org-context:read +``` + +API Key 발급/회수는 사람의 관리 행위이므로 super admin 권한으로만 수행한다. 반면 아래 조직 Context 조회 API는 사용자 세션 없이 API Key만으로 동작한다. + +## Endpoint + +```http +GET /api/v1/integrations/org-context +``` + +### Query + +| 이름 | 기본값 | 설명 | +| --- | --- | --- | +| `tenantSlug` | `hanmac-family` | 조회할 subtree root tenant slug. 지정하지 않으면 `hanmac-family` 전체 subtree를 반환한다. | +| `includeUsers` | `true` | `false`이면 `users`와 `directUserIds`를 비운다. | + +상위 조직 지정은 slug만 사용한다. UUID 기반 지정은 계약에 포함하지 않는다. + +## 예시 요청 + +```bash +curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&includeUsers=true' \ + -H 'X-Baron-Key-ID: 01970f08-91da-7286-bd19-882fb98d1f2c' \ + -H 'X-Baron-Key-Secret: ' +``` + +## 예시 응답 + +```json +{ + "schemaVersion": "baron.org-context.v1", + "issuedAt": "2026-05-13T12:00:00Z", + "scope": { + "tenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "tenantSlug": "hanmac" + }, + "tree": { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "type": "COMPANY", + "name": "한맥기술", + "slug": "hanmac", + "parentId": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "status": "active", + "description": "", + "domains": [], + "memberCount": 0, + "visibility": "public", + "createdAt": "2026-05-13T00:00:00Z", + "updatedAt": "2026-05-13T00:00:00Z", + "directUserIds": [], + "children": [ + { + "id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a", + "type": "USER_GROUP", + "name": "플랫폼실", + "slug": "platform", + "parentId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "status": "active", + "description": "", + "domains": [], + "memberCount": 0, + "visibility": "internal", + "orgUnitType": "실", + "createdAt": "2026-05-13T00:00:00Z", + "updatedAt": "2026-05-13T00:00:00Z", + "directUserIds": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" + ], + "children": [] + } + ] + }, + "tenants": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "type": "COMPANY", + "name": "한맥기술", + "slug": "hanmac", + "parentId": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "status": "active", + "description": "", + "domains": [], + "memberCount": 0, + "visibility": "public", + "createdAt": "2026-05-13T00:00:00Z", + "updatedAt": "2026-05-13T00:00:00Z" + } + ], + "users": [ + { + "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "email": "user@example.com", + "name": "홍길동", + "role": "user", + "status": "active", + "tenantIds": [ + "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a" + ], + "tenantSlugs": [ + "platform" + ], + "grade": "책임", + "position": "실장", + "jobTitle": "Backend Engineer", + "createdAt": "2026-05-13T00:00:00Z", + "updatedAt": "2026-05-13T00:00:00Z" + } + ] +} +``` + +## 정책 + +- `tenantSlug`가 없으면 `hanmac-family` 전체 subtree를 반환한다. +- `tenantSlug`는 slug만 허용한다. UUID를 query 계약으로 쓰지 않는다. +- `visibility=private` tenant와 그 하위 tenant는 제외한다. +- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다. +- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다. diff --git a/docs/rp-iam-integration-guide.md b/docs/rp-iam-integration-guide.md index 20a4488a..4185717a 100644 --- a/docs/rp-iam-integration-guide.md +++ b/docs/rp-iam-integration-guide.md @@ -34,6 +34,229 @@ flowchart TD G --> H[RP never parses or stores raw kratos_identity_id] ``` +## OIDC Tenant Claim Contract + +Baron은 기본적으로 대표소속 tenant와 전체 소속 tenant 목록을 식별할 수 있도록 `tenant_id`, `joined_tenants`를 ID token claim에 포함할 수 있습니다. RP가 OIDC scope 또는 client metadata 정책을 통해 `tenant` claim을 요청하면 Baron은 여기에 더해 tenant별 상세 정보를 포함합니다. 이 claim은 RP가 UI 표시, 조직 맥락 선택, RP 내부 권한 매핑을 시작하기 위한 입력이며, 최종 권한 판정은 Baron gateway/Keto check 또는 Baron이 발급한 trusted header를 기준으로 해야 합니다. + +기본 claim 구조는 다음과 같습니다. + +```json +{ + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "joined_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + ] +} +``` + +`tenant` claim을 요청하면 상세 claim 구조는 다음과 같습니다. + +```json +{ + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "joined_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + ], + "lead_tenants": ["01970f0a-5c28-74d8-a73a-f6e9e9a7b210"], + "tenants": { + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210": { + "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "slug": "tech-planning", + "name": "기술기획팀", + "type": "USER_GROUP", + "lead": true, + "representative": true, + "isPrimary": true, + "grade": "책임", + "jobTitle": "기술기획", + "position": "팀장", + "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "ancestors": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "slug": "hanmac", + "name": "한맥기술", + "type": "COMPANY", + "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345" + }, + { + "id": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "slug": "hanmac-family", + "name": "한맥가족", + "type": "COMPANY_GROUP", + "parentTenantId": null + } + ] + }, + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661": { + "id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", + "slug": "quality", + "name": "품질관리팀", + "type": "USER_GROUP", + "lead": false, + "representative": false, + "isPrimary": false, + "grade": "선임", + "jobTitle": "품질관리", + "position": "파트원", + "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "ancestors": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "slug": "hanmac", + "name": "한맥기술", + "type": "COMPANY", + "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345" + }, + { + "id": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "slug": "hanmac-family", + "name": "한맥가족", + "type": "COMPANY_GROUP", + "parentTenantId": null + } + ] + } + } +} +``` + +필드 의미는 다음과 같습니다. + +- `tenant_id`: 기본 claim입니다. 사용자의 대표소속 tenant UUID입니다. 현재 RP/client context tenant가 없더라도 공백으로 내려가지 않습니다. +- `joined_tenants`: 기본 claim입니다. 사용자가 claim 상에서 소속된 모든 tenant UUID 목록입니다. `additionalAppointments`의 모든 한맥가족 subtree tenant를 포함합니다. +- `lead_tenants`: `tenant` claim 요청 시 포함됩니다. `lead=true`로 판정된 tenant id 목록입니다. +- `tenants`: `tenant` claim 요청 시 포함됩니다. tenant UUID를 key로 하는 tenant별 claim map입니다. 멀티 소속이면 소속 tenant마다 하나씩 포함되며, `slug`는 별도 필드로 내려갑니다. +- `tenants.*.lead`: 해당 tenant에서 lead 권한 또는 조직장 역할이 있으면 `true`입니다. Baron 입력에서는 `lead`, `isLead`, `isOwner`, `isManager`를 수용할 수 있습니다. +- `tenants.*.representative`: 대표조직이면 `true`입니다. Baron 입력에서는 `representative`, `isPrimary`, `primary`를 수용할 수 있습니다. +- `tenants.*.grade`: 직급입니다. +- `tenants.*.jobTitle`: 직무입니다. +- `tenants.*.position`: 직책입니다. +- `tenants.*.parentTenantId`: 현재 tenant의 직속 parent tenant UUID입니다. 최상위 root면 `null`입니다. +- `tenants.*.ancestors`: 직속 상위 tenant부터 `hanmac-family` root까지의 parent chain입니다. + +대표소속 결정 정책은 다음과 같습니다. + +- 명시적인 `tenant_id`가 있으면 이를 대표소속으로 사용합니다. +- 명시적인 대표소속이 없으면 `additionalAppointments`에서 `representative=true`, `isPrimary=true`, `primary=true`인 소속을 사용합니다. +- 대표 표시가 없으면 가장 먼저 등록된 소속 tenant를 대표소속으로 사용합니다. +- 생성 시 소속 tenant가 하나도 없으면 Baron이 PERSONAL tenant를 자동 생성하고, 해당 PERSONAL tenant UUID를 `tenant_id`와 `joined_tenants`에 포함합니다. +- RP/client의 tenant context는 대표소속을 덮어쓰지 않습니다. RP context tenant가 필요한 경우 별도 필드나 RP route context로 다뤄야 합니다. + +한맥가족(`hanmac-family`) subtree에 속한 tenant claim은 다음 규칙을 따릅니다. + +- `TenantService.GetTenant` 기준 parent chain이 `hanmac-family` root에 도달한 경우에만 한맥가족 확장 필드를 보강합니다. +- `additionalAppointments`만 존재하고 tenant별 namespaced traits map이 없어도 `tenant_id` 또는 `additionalAppointments[].tenantId`를 기준으로 `tenants` 항목을 생성할 수 있습니다. +- 사용자가 여러 tenant에 소속되면 기본 claim인 `joined_tenants`에는 모든 소속 tenant가 포함됩니다. +- `tenant` claim 요청 시 `tenants`에도 모든 소속 tenant의 상세가 포함되고, `lead_tenants`에는 그중 `lead=true`인 tenant만 포함됩니다. +- 직급/직무/직책과 대표조직/lead 여부는 사용자 소속 metadata(`additionalAppointments`)를 우선합니다. +- `ancestors`는 직속 상위 tenant부터 root 방향으로 정렬되며, root가 `hanmac-family`일 때까지만 포함합니다. +- 기본 tenant와 각 ancestor 객체는 `parentTenantId`를 포함합니다. 이 필드로 parent edge를 바로 그릴 수 있습니다. + +주의사항: + +- Tenant tree, 직급, 직무, 직책은 PostgreSQL Business SoT와 tenant/user metadata를 기준으로 합니다. Kratos traits는 인증 식별 정보 중심으로 유지해야 하며, 관계형 데이터의 영구 SoT로 취급하지 않습니다. +- Token 크기가 커질 수 있으므로 RP가 긴 조직 전체 정보를 필요로 하면 ID token claim보다 userinfo/profile API 또는 Baron backend API 연동을 우선 검토합니다. +- RP는 `lead_tenants` 또는 `tenants.*.lead`만으로 보안상 중요한 권한을 단독 판정하지 않습니다. 권한 변경/민감 리소스 접근은 Keto 기반 Baron authorization contract를 함께 사용해야 합니다. + +### Issue #775 구현 결과 예시 + +아래 예시는 #775 구현 후 `tenant_id=01970f0a-5c28-74d8-a73a-f6e9e9a7b210`, 사용자 `additionalAppointments`에 대표 소속 `tech-planning`과 겸직 소속 `quality`가 함께 있고, 두 tenant의 parent chain이 `hanmac -> hanmac-family`로 이어지는 경우 ID token에 내려가는 데이터 형태입니다. 예시의 `id` 값은 UUID 형식의 샘플이며, `slug`와 다릅니다. + +```json +{ + "email": "hanmac-user@example.com", + "name": "한맥 사용자", + "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "joined_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661" + ], + "lead_tenants": [ + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210" + ], + "tenants": { + "01970f0a-5c28-74d8-a73a-f6e9e9a7b210": { + "id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", + "slug": "tech-planning", + "name": "기술기획팀", + "type": "USER_GROUP", + "lead": true, + "representative": true, + "isPrimary": true, + "grade": "책임", + "jobTitle": "기술기획", + "position": "팀장", + "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "ancestors": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "slug": "hanmac", + "name": "한맥기술", + "type": "COMPANY", + "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345" + }, + { + "id": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "slug": "hanmac-family", + "name": "한맥가족", + "type": "COMPANY_GROUP", + "parentTenantId": null + } + ] + }, + "01970f0b-3448-7bb8-bdc7-16b6a1d2e661": { + "id": "01970f0b-3448-7bb8-bdc7-16b6a1d2e661", + "slug": "quality", + "name": "품질관리팀", + "type": "USER_GROUP", + "lead": false, + "representative": false, + "isPrimary": false, + "grade": "선임", + "jobTitle": "품질관리", + "position": "파트원", + "parentTenantId": "01970f08-91da-7286-bd19-882fb98d1f2c", + "ancestors": [ + { + "id": "01970f08-91da-7286-bd19-882fb98d1f2c", + "slug": "hanmac", + "name": "한맥기술", + "type": "COMPANY", + "parentTenantId": "01970f07-4f01-7d9a-a71e-b53ad508f345" + }, + { + "id": "01970f07-4f01-7d9a-a71e-b53ad508f345", + "slug": "hanmac-family", + "name": "한맥가족", + "type": "COMPANY_GROUP", + "parentTenantId": null + } + ] + } + }, + "profile": { + "emails": [ + "hanmac-user@example.com" + ], + "names": { + "name": "한맥 사용자" + } + } +} +``` + +RP 소비 기준: + +- lead tenant를 빠르게 찾을 때는 `lead_tenants`를 우선 사용합니다. +- 전체 소속 tenant 목록은 `joined_tenants`로 읽고, 각 소속의 상세 조직 맥락은 `tenants[joined_tenants[n]]`에서 읽습니다. +- 대표소속의 상세 조직 맥락은 `tenants[tenant_id]`에서 읽습니다. +- 상위 조직 breadcrumb은 `tenants[tenant_id].ancestors`를 직속 상위부터 root 방향으로 표시합니다. +- 조직 트리 edge는 기본 tenant와 각 ancestor의 `parentTenantId`를 사용해 그립니다. +- 대표조직 여부는 `representative`를 우선 사용하고, 기존 primary 표현 호환이 필요하면 `isPrimary`를 함께 읽습니다. + ## obj_id 조회 흐름 `obj_id`는 Keto check의 target object입니다. 명시적으로 전달된 `obj_id`가 있으면 정규화 후 사용하고, 없으면 route context에서 `client_id`, `tenant_id` 순서로 추론합니다. 둘 다 없으면 RP가 명확한 target object를 제공하지 않은 것이므로 요청을 거부해야 합니다. diff --git a/orgfront/src/features/auth/AuthGuard.test.tsx b/orgfront/src/features/auth/AuthGuard.test.tsx new file mode 100644 index 00000000..29d1d4ac --- /dev/null +++ b/orgfront/src/features/auth/AuthGuard.test.tsx @@ -0,0 +1,77 @@ +import { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import AuthGuard from "./AuthGuard"; + +const authState = { + isAuthenticated: false, + isLoading: false, + activeNavigator: undefined as string | undefined, + error: undefined as Error | undefined, + removeUser: vi.fn(), +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +function LocationProbe() { + const location = useLocation(); + return ( +

+ {location.pathname} + {location.search} +
+ ); +} + +function renderGuard(initialEntry: string) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + + }> + picker} /> + + } /> + + , + ); + }); + + return { container, root }; +} + +function cleanupRendered(container: HTMLDivElement, root: Root) { + act(() => { + root.unmount(); + }); + container.remove(); +} + +describe("OrgFront AuthGuard auto login redirects", () => { + afterEach(() => { + vi.clearAllMocks(); + authState.isAuthenticated = false; + authState.isLoading = false; + authState.activeNavigator = undefined; + authState.error = undefined; + window.localStorage.clear(); + }); + + it("redirects protected picker entry to the auto login URL", () => { + const rendered = renderGuard( + "/embed/picker?mode=single&select=tenant&tenantId=hanmac-family-id", + ); + + expect(rendered.container.textContent).toBe( + "/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26tenantId%3Dhanmac-family-id", + ); + cleanupRendered(rendered.container, rendered.root); + }); +}); diff --git a/orgfront/src/features/auth/AuthGuard.tsx b/orgfront/src/features/auth/AuthGuard.tsx index cd2ceb5f..bf2cfd6a 100644 --- a/orgfront/src/features/auth/AuthGuard.tsx +++ b/orgfront/src/features/auth/AuthGuard.tsx @@ -30,7 +30,7 @@ export default function AuthGuard() { const returnTo = `${location.pathname}${location.search}`; return ( ); diff --git a/orgfront/vitest.config.ts b/orgfront/vitest.config.ts index 55dd458b..f2474f57 100644 --- a/orgfront/vitest.config.ts +++ b/orgfront/vitest.config.ts @@ -1,8 +1,17 @@ +import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], + server: { + fs: { + allow: [ + fileURLToPath(new URL(".", import.meta.url)), + fileURLToPath(new URL("../common", import.meta.url)), + ], + }, + }, test: { globals: true, environment: "jsdom",