1
0
forked from baron/baron-sso

조직도 M2M조회 추가, 자동로그인 보완

This commit is contained in:
2026-05-13 13:44:30 +09:00
parent 72288f1d39
commit 8c2b2f71ef
29 changed files with 2985 additions and 81 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# General
.env
.env_backup
.temp
.DS_Store
.idea/

View File

@@ -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() {

View File

@@ -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() {
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => exportChildrenMutation.mutate(selectedNode.id)}
disabled={exportChildrenMutation.isPending}
data-testid="tenant-subtree-export-btn"
>
<Download size={16} className="mr-2" />
{t("ui.admin.tenants.sub.export", "하위 조직 CSV")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -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<Blob>("/v1/admin/tenants/export", {
params: { includeIds },
params: { includeIds, parentId: parentId || undefined },
responseType: "blob",
});
const dispositionHeader = response.headers["content-disposition"];

View File

@@ -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,
}) => {

View File

@@ -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},
}

View File

@@ -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,

View File

@@ -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:"))
}

View File

@@ -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:

View File

@@ -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",

View File

@@ -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)

View File

@@ -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{}{

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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"))
}

View File

@@ -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<string | null>(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() {
)}
</Button>
{visibleLoginError ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{visibleLoginError}</span>
</div>
) : null}
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
.
<br />

View File

@@ -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);
}
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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`를 조합한다.

View File

@@ -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: <client id>
X-Baron-Key-Secret: <client 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: <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을 적용한다.

View File

@@ -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를 제공하지 않은 것이므로 요청을 거부해야 합니다.

View File

@@ -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 (
<div data-testid="location">
{location.pathname}
{location.search}
</div>
);
}
function renderGuard(initialEntry: string) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route element={<AuthGuard />}>
<Route path="/embed/picker" element={<div>picker</div>} />
</Route>
<Route path="/login" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
});
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);
});
});

View File

@@ -30,7 +30,7 @@ export default function AuthGuard() {
const returnTo = `${location.pathname}${location.search}`;
return (
<Navigate
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
to={`/login?auto=1&returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);

View File

@@ -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",