forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
@@ -32,7 +32,8 @@ GET /api/v1/integrations/org-context
|
||||
| 이름 | 기본값 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| `tenantSlug` | `hanmac-family` | 조회할 subtree root tenant slug. 지정하지 않으면 `hanmac-family` 전체 subtree를 반환한다. |
|
||||
| `includeUsers` | `true` | `false`이면 `users`와 `directUserIds`를 비운다. |
|
||||
| `includeUsers` | `true` | `false`이면 각 tenant의 `members`를 빈 배열로 반환한다. |
|
||||
| `includeUserIds` | `false` | `true`이면 각 tenant의 `members[].id`와 `members[].phone`만 추가한다. 사용자 UUID와 전화번호가 필요한 연동에서만 사용한다. |
|
||||
|
||||
상위 조직 지정은 slug만 사용한다. UUID 기반 지정은 계약에 포함하지 않는다.
|
||||
|
||||
@@ -67,7 +68,7 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
||||
"visibility": "public",
|
||||
"createdAt": "2026-05-13T00:00:00Z",
|
||||
"updatedAt": "2026-05-13T00:00:00Z",
|
||||
"directUserIds": [],
|
||||
"members": [],
|
||||
"children": [
|
||||
{
|
||||
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
|
||||
@@ -83,8 +84,17 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
||||
"orgUnitType": "실",
|
||||
"createdAt": "2026-05-13T00:00:00Z",
|
||||
"updatedAt": "2026-05-13T00:00:00Z",
|
||||
"directUserIds": [
|
||||
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
||||
"members": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"name": "홍길동",
|
||||
"grade": "책임",
|
||||
"position": "실장",
|
||||
"jobTitle": "Backend Engineer",
|
||||
"isOwner": true,
|
||||
"isLeader": true,
|
||||
"isPrimary": true
|
||||
}
|
||||
],
|
||||
"children": []
|
||||
}
|
||||
@@ -103,27 +113,35 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
||||
"memberCount": 0,
|
||||
"visibility": "public",
|
||||
"createdAt": "2026-05-13T00:00:00Z",
|
||||
"updatedAt": "2026-05-13T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
"updatedAt": "2026-05-13T00:00:00Z",
|
||||
"members": []
|
||||
},
|
||||
{
|
||||
"id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
|
||||
"email": "user@example.com",
|
||||
"name": "홍길동",
|
||||
"role": "user",
|
||||
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
|
||||
"type": "USER_GROUP",
|
||||
"name": "플랫폼실",
|
||||
"slug": "platform",
|
||||
"parentId": "01970f08-91da-7286-bd19-882fb98d1f2c",
|
||||
"status": "active",
|
||||
"tenantIds": [
|
||||
"01970f09-2b7b-7f83-b9d6-4f6c8b33f01a"
|
||||
],
|
||||
"tenantSlugs": [
|
||||
"platform"
|
||||
],
|
||||
"grade": "책임",
|
||||
"position": "실장",
|
||||
"jobTitle": "Backend Engineer",
|
||||
"description": "",
|
||||
"domains": [],
|
||||
"memberCount": 0,
|
||||
"visibility": "internal",
|
||||
"orgUnitType": "실",
|
||||
"createdAt": "2026-05-13T00:00:00Z",
|
||||
"updatedAt": "2026-05-13T00:00:00Z"
|
||||
"updatedAt": "2026-05-13T00:00:00Z",
|
||||
"members": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"name": "홍길동",
|
||||
"grade": "책임",
|
||||
"position": "실장",
|
||||
"jobTitle": "Backend Engineer",
|
||||
"isOwner": true,
|
||||
"isLeader": true,
|
||||
"isPrimary": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -136,3 +154,12 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
||||
- `visibility=private` tenant와 그 하위 tenant는 제외한다.
|
||||
- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다.
|
||||
- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
|
||||
- `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다.
|
||||
- 사용자 목록은 top-level `users`가 아니라 각 tenant의 `members`에 직접 소속 사용자 배열로 제공한다.
|
||||
- tenant 세부 분류는 `type`과 `orgUnitType`으로 구분한다. `orgUnitType`은 tenant `config.orgUnitType` 값이 있을 때만 포함한다.
|
||||
- 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다.
|
||||
- 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id`와 `members[].phone`만 추가된다.
|
||||
- `isOwner`는 appointment metadata의 `isOwner` 또는 `isManager` 기준이다.
|
||||
- `isLeader`는 appointment metadata의 `lead` 또는 `isLead` 기준이며, `isOwner`/`isManager`가 true인 경우에도 true로 본다.
|
||||
- `isPrimary`는 appointment metadata의 `representative`, `isPrimary`, `primary` 기준이다.
|
||||
- appointment별 `grade`, `position`, `jobTitle`, `department`가 있으면 해당 tenant membership 값으로 우선 사용한다.
|
||||
|
||||
82
docs/kratos-user-traits-field-inventory.md
Normal file
82
docs/kratos-user-traits-field-inventory.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Kratos 사용자 traits 필드 인벤토리
|
||||
|
||||
작성일: 2026-05-13
|
||||
|
||||
## 확인 대상
|
||||
|
||||
- 설정 파일: `docker/ory/kratos/identity.schema.json`
|
||||
- 로컬 Kratos DB: `ory_postgres` / `ory_kratos.identities.traits`
|
||||
- 전역 Personal 테넌트: `9607eb7b-04d2-42ab-80fe-780fe21c7e8f` / `personal`
|
||||
|
||||
## Kratos schema에 설정된 traits 필드
|
||||
|
||||
| 필드 | 타입 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| `custom_login_ids` | string array | password identifier |
|
||||
| `email` | string | password/code identifier, recovery, verification, required |
|
||||
| `name` | string | 사용자 이름 |
|
||||
| `phone_number` | string | password/code SMS identifier |
|
||||
| `department` | string | 부서 |
|
||||
| `affiliationType` | string | 소속 유형 |
|
||||
| `companyCode` | string | 대표 테넌트 slug |
|
||||
| `role` | string | 권한 역할 |
|
||||
| `tenant_id` | string | 대표 테넌트 UUID |
|
||||
| `displayname` | string | 레거시 표시 이름 후보 |
|
||||
| `completeForm` | boolean | 레거시 가입 폼 완료 여부 후보 |
|
||||
| `team` | string | 레거시 팀 후보 |
|
||||
| `taxCode` | string | 레거시 세무 코드 후보 |
|
||||
| `familyCompany` | string | 레거시 가족사 후보 |
|
||||
| `familyUniqueKey` | string | 레거시 가족사 고유키 후보 |
|
||||
| `personal` | boolean | 레거시 Personal 여부 후보 |
|
||||
| `grade` | string | 직급 |
|
||||
|
||||
현재 schema는 `additionalProperties: true`라서 위 목록에 없는 traits도 저장 가능합니다.
|
||||
|
||||
## 로컬 Kratos DB에 실제 저장된 traits 필드
|
||||
|
||||
| 필드 | identity 수 |
|
||||
| --- | ---: |
|
||||
| `affiliationType` | 3 |
|
||||
| `companyCode` | 3 |
|
||||
| `companyCodes` | 1 |
|
||||
| `department` | 3 |
|
||||
| `email` | 3 |
|
||||
| `grade` | 3 |
|
||||
| `name` | 3 |
|
||||
| `phone_number` | 1 |
|
||||
| `role` | 3 |
|
||||
| `tenant_id` | 1 |
|
||||
|
||||
## 정리 후보
|
||||
|
||||
유지 후보:
|
||||
|
||||
- 인증 식별자: `email`, `phone_number`, `custom_login_ids`
|
||||
- 사용자 기본 프로필: `name`
|
||||
- 권한/대표 소속: `role`, `tenant_id`, `companyCode`
|
||||
- 조직 표시/연동: `department`, `grade`
|
||||
- 다중 소속이 필요한 동안 유지: `companyCodes`
|
||||
|
||||
schema 추가 검토 후보:
|
||||
|
||||
- backend projection에서 읽는 `position`, `jobTitle`
|
||||
- 한맥가족 다중 소속을 metadata로 유지할 경우 `additionalAppointments`
|
||||
- 대표 테넌트 표시값을 traits로 계속 줄 경우 `primaryTenantId`, `primaryTenantSlug`, `primaryTenantName`, `primaryTenantIsOwner`
|
||||
|
||||
제거 후보:
|
||||
|
||||
- `displayname`
|
||||
- `completeForm`
|
||||
- `team`
|
||||
- `taxCode`
|
||||
- `familyCompany`
|
||||
- `familyUniqueKey`
|
||||
- `personal`
|
||||
- `hanmacFamily`는 이미 `test/kratos_identity_schema_policy_test.sh`에서 금지 필드로 검사 중입니다.
|
||||
|
||||
## 제안 정책
|
||||
|
||||
1. Personal 사용자는 사용자별 Personal 테넌트를 생성하지 않고 전역 `personal` 테넌트만 사용합니다.
|
||||
2. Kratos traits는 인증/클레임에 필요한 최소 필드만 유지합니다.
|
||||
3. 조직도나 연동 전용 확장 데이터는 traits 최상위에 흩뿌리지 않고 Baron DB의 user projection 또는 명시된 metadata 구조로 모읍니다.
|
||||
4. `additionalProperties: true`를 바로 `false`로 바꾸면 기존 identity 갱신이 실패할 수 있으므로, 먼저 backend sanitizer와 마이그레이션으로 제거 후보를 정리한 뒤 schema를 닫습니다.
|
||||
142
docs/tenant-maintenance-procedures.md
Normal file
142
docs/tenant-maintenance-procedures.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Tenant 유지보수 절차
|
||||
|
||||
이 문서는 관리자가 비정기적으로 수행할 수 있는 tenant 데이터 정합성 점검 및 정리 절차를 정리한다.
|
||||
|
||||
## Orphan 사용자 tenant 소속정보 정리
|
||||
|
||||
### 대상
|
||||
|
||||
다음 중 하나에 해당하는 활성 Baron 사용자는 orphan 소속정보 정리 대상이다.
|
||||
|
||||
- `users.tenant_id`가 존재하지 않거나 soft-deleted 된 `tenants.id`를 가리킨다.
|
||||
- `users.company_code`가 활성 `tenants.slug`와 매칭되지 않는다.
|
||||
- `users.company_codes` 배열 중 하나 이상이 활성 `tenants.slug`와 매칭되지 않는다.
|
||||
|
||||
정리 시 다음 필드를 비운다.
|
||||
|
||||
- `tenant_id`
|
||||
- `company_code`
|
||||
- `company_codes`
|
||||
|
||||
### 사전 확인
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
u.email,
|
||||
u.tenant_id,
|
||||
u.company_code,
|
||||
u.company_codes
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
(
|
||||
u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = u.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
OR (
|
||||
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
|
||||
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Kratos identity traits도 같은 기준으로 정리한다.
|
||||
|
||||
- `traits.tenant_id`
|
||||
- `traits.companyCode`
|
||||
- `traits.companyCodes`
|
||||
|
||||
### Baron users만 실행
|
||||
|
||||
```bash
|
||||
docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
|
||||
```
|
||||
|
||||
실행 결과에는 정리된 사용자와 기존 소속정보가 출력된다.
|
||||
|
||||
### Baron users와 Kratos identity traits 함께 실행
|
||||
|
||||
로컬 Docker Compose 기준 기본 컨테이너명은 다음과 같다.
|
||||
|
||||
- Baron DB: `baron_postgres`
|
||||
- Kratos DB: `ory_postgres`
|
||||
|
||||
```bash
|
||||
scripts/clear_orphan_tenant_memberships.sh
|
||||
```
|
||||
|
||||
컨테이너명이나 DB 접속 정보가 다르면 환경변수로 override 한다.
|
||||
|
||||
```bash
|
||||
BARON_CONTAINER=baron_postgres \
|
||||
BARON_DB_USER=baron \
|
||||
BARON_DB_NAME=baron_sso \
|
||||
KRATOS_CONTAINER=ory_postgres \
|
||||
KRATOS_DB_USER=ory \
|
||||
KRATOS_DB_NAME=ory_kratos \
|
||||
scripts/clear_orphan_tenant_memberships.sh
|
||||
```
|
||||
|
||||
### 사후 확인
|
||||
|
||||
사전 확인 쿼리를 다시 실행했을 때 결과가 0건이어야 한다.
|
||||
|
||||
Kratos identity traits는 다음 쿼리로 확인한다.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
id,
|
||||
traits->>'email' AS email,
|
||||
traits->>'tenant_id' AS tenant_id,
|
||||
traits->>'companyCode' AS company_code,
|
||||
traits->'companyCodes' AS company_codes
|
||||
FROM identities
|
||||
WHERE COALESCE(traits->>'tenant_id', '') <> ''
|
||||
OR COALESCE(traits->>'companyCode', '') <> ''
|
||||
OR traits ? 'companyCodes';
|
||||
```
|
||||
|
||||
## Soft-deleted tenant slug 점검
|
||||
|
||||
`tenants.slug`는 DB unique index이므로 soft-deleted row도 slug를 점유한다. 현재 삭제 로직은 삭제 전에 slug에 `-deleted-...` suffix를 붙여 재사용 가능하게 만들지만, 과거 데이터나 수동 변경으로 삭제된 row가 원래 slug를 계속 점유하면 AdminFront 검색에는 보이지 않으면서 생성은 unique violation으로 실패할 수 있다.
|
||||
|
||||
### legacy 점유 row 확인
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
deleted_at
|
||||
FROM tenants
|
||||
WHERE deleted_at IS NOT NULL
|
||||
AND slug NOT LIKE '%-deleted-%'
|
||||
ORDER BY deleted_at DESC;
|
||||
```
|
||||
|
||||
### 정책
|
||||
|
||||
- 활성 tenant가 같은 slug를 가지고 있으면 생성 실패가 정상이다.
|
||||
- soft-deleted tenant만 같은 slug를 점유하고 있으면 생성 직전에 해당 deleted row의 slug를 release 한다.
|
||||
- `FindBySlug`와 검색 API는 활성 tenant만 반환하므로, 생성 제약도 활성 tenant 기준으로 체감되도록 맞춘다.
|
||||
59
docs/tenant-visibility-exposure-policy.md
Normal file
59
docs/tenant-visibility-exposure-policy.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Tenant visibility exposure policy
|
||||
|
||||
## 목적
|
||||
|
||||
`hanmac-family` 하위 조직의 `config.visibility` 값이 호출 위치별로 어디까지 노출되는지 정리한다. 현재 정책에서 완전 공개 조직도는 없다고 본다. 따라서 `public`은 인터넷 전체 공개가 아니라 인증/공유 경계 안에서의 기본 노출값이다.
|
||||
|
||||
## Visibility 값
|
||||
|
||||
| 값 | 의미 | 기본값 여부 |
|
||||
| --- | --- | --- |
|
||||
| `public` | 인증된 사용자 화면과 통제된 공유 경계에서 기본 노출 가능한 조직. 현재 정책상 인터넷 완전 공개를 의미하지 않음 | `visibility`가 없거나 빈 값이면 `public` |
|
||||
| `internal` | Baron 로그인 세션 또는 M2M API Key를 가진 내부/연동 경계에는 노출하지만, 외부 공유 경계에는 노출하지 않는 조직 | 명시 설정 필요 |
|
||||
| `private` | 기본 조직도 노출 대상에서 제외하는 조직. 해당 조직의 하위 조직도 함께 제외 | 명시 설정 필요 |
|
||||
|
||||
`visibility`와 `orgUnitType` 설정은 현재 `hanmac-family` descendant tenant에만 허용된다. `hanmac-family` root 자체에는 org config를 두지 않는 정책이다.
|
||||
|
||||
## 호출 위치별 노출 기준
|
||||
|
||||
| 호출 위치 | 인증/권한 경계 | `public` | `internal` | `private` |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| OrgFront 일반 조직도 `/chart` | 로그인 세션, backend `/api/v1/admin/tenants` 기반 | 노출 | 노출 | 권한 없는 사용자는 미노출 |
|
||||
| OrgFront embed picker `/embed/picker` | 로그인 세션 또는 picker 자동 로그인 경로 | 노출 | 노출 | 권한 없는 사용자는 미노출 |
|
||||
| 공유 링크 `/api/v1/public/orgchart?token=...` | share token. 완전 공개가 아니라 통제된 공유 경계 | 노출 | 미노출 | 미노출 |
|
||||
| M2M 조직 Context JSON API `/api/v1/integrations/org-context` | API Key + `org-context:read` | 노출 | 노출 | 미노출 |
|
||||
| AdminFront 테넌트 관리 `/api/v1/admin/tenants` | 사용자 role/Keto 관리 경계 | 노출 | 노출 | `super_admin`, 해당 private subtree owner/admin/manage 가능 사용자, 또는 명시적 private 조회 권한자만 노출 |
|
||||
| AdminFront CSV export | 사용자 role/Keto 관리 경계 | 노출 | 노출 | `/api/v1/admin/tenants`와 동일 |
|
||||
| AdminFront CSV import | 권한 있는 관리자 작업 | 입력값 검증 대상 | 입력값 검증 대상 | 입력값 검증 대상 |
|
||||
|
||||
## 핵심 판단
|
||||
|
||||
`internal`은 현재 “특정 조직 권한자에게만 보이는 조직”이 아니다. 로그인된 OrgFront 사용자가 backend에서 해당 tenant family를 받을 수 있는 경우, OrgFront 기본 조직도와 picker에는 `internal` 조직이 기본 노출된다.
|
||||
|
||||
다만 `internal`은 공개 공유 링크에서는 제외된다. 외부 M2M JSON API에서는 API Key 자체가 신뢰 경계이므로 `internal`을 포함한다.
|
||||
|
||||
`private`은 기본적으로 일반 조직도, picker, 공유 링크, M2M JSON API에서 제외된다. 또한 `private` 조직 아래의 descendant도 함께 제외된다. AdminFront 테넌트 관리와 CSV export에서도 권한 없는 사용자에게는 제외된다.
|
||||
|
||||
`private` 노출이 허용되는 사용자는 다음 중 하나다.
|
||||
|
||||
- `super_admin`
|
||||
- 해당 private 조직 또는 ancestor를 `ManageableTenants`로 가진 owner/admin/manage 가능 사용자
|
||||
- Keto/ReBAC에서 해당 private 조직에 `view_private`, `view_private_descendants`, `view`, `manage` 중 하나를 가진 사용자
|
||||
- ancestor tenant에 `view_private_descendants`를 가진 사용자
|
||||
|
||||
## 구현 근거
|
||||
|
||||
- backend `tenantVisibility`는 `internal`, `private`만 별도 값으로 인정하고 나머지는 `public`으로 처리한다.
|
||||
- backend `filterPublicTenants`는 `internal`, `private`, 그리고 그 descendant를 공개 공유 링크 노출에서 제외한다.
|
||||
- backend `filterOrgContextSubtree`는 `private`과 그 descendant만 M2M 조직 Context에서 제외한다. 따라서 `internal`은 포함된다.
|
||||
- backend `/api/v1/admin/tenants`와 CSV export는 사용자 profile과 Keto 권한을 기준으로 private root 및 descendant를 필터링한다.
|
||||
- OrgFront `filterTenantsByVisibility(..., "internal")`은 `private`만 제외한다. 일반 `/chart`와 `/embed/picker`는 backend에서 이미 권한 필터링된 tenant 목록 위에서 이 기준을 사용한다.
|
||||
- OrgFront `filterTenantsByVisibility(..., "public")`은 `internal`, `private`를 제외한다. share token 기반 공개 조직도가 이 기준을 사용한다.
|
||||
|
||||
## 회색지대
|
||||
|
||||
현재 이름만 보면 `public`이 인터넷 완전 공개라는 의미로 해석될 수 있지만, 정책상 완전 공개 조직도는 없고 `public`은 기본 노출값이다.
|
||||
|
||||
현재 이름만 보면 `internal`이 “조직 내부 구성원 또는 특정 권한자만”이라는 의미로 해석될 수 있지만, 구현은 “공유 링크에는 숨기고, 로그인/M2M 경계에는 노출”이다.
|
||||
|
||||
특정 권한자에게만 보이는 조직은 `private`과 Keto/ReBAC 권한으로 표현한다. `internal`을 제한 공개 권한 모델로 확장하려면 별도 정책 변경이 필요하다.
|
||||
Reference in New Issue
Block a user