forked from baron/baron-sso
조직도 M2M조회 추가, 자동로그인 보완
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# General
|
||||
.env
|
||||
.env_backup
|
||||
.temp
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
backend/cmd/server/openapi_static_test.go
Normal file
39
backend/cmd/server/openapi_static_test.go
Normal 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:"))
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
155
backend/internal/handler/tenant_assignment_policy.go
Normal file
155
backend/internal/handler/tenant_assignment_policy.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
15
backend/internal/middleware/api_key_auth_test.go
Normal file
15
backend/internal/middleware/api_key_auth_test.go
Normal 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"))
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
39
devfront/tests/devfront-login.spec.ts
Normal file
39
devfront/tests/devfront-login.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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`를 조합한다.
|
||||
|
||||
138
docs/integrations-org-context-json-api.md
Normal file
138
docs/integrations-org-context-json-api.md
Normal 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을 적용한다.
|
||||
@@ -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를 제공하지 않은 것이므로 요청을 거부해야 합니다.
|
||||
|
||||
77
orgfront/src/features/auth/AuthGuard.test.tsx
Normal file
77
orgfront/src/features/auth/AuthGuard.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user