# Custom Field JSONB 및 인덱스 정책 ## 현재 구조 - Tenant custom schema는 `tenants.config.userSchema` JSONB에 저장한다. - Tenant custom value는 Ory에 저장되지 않거나 Ory API로 필요한 조회가 불가능한 값에 한해 backend DB의 `users.metadata` JSONB read model에 저장한다. - `isLoginId=true`인 Tenant field 값은 로그인 식별자 처리를 위해 `user_login_ids`에도 동기화한다. - Ory Kratos traits에는 인증/식별에 필요한 최소 값만 동기화하는 방향으로 정리한다. - RP custom value는 Ory에 저장되지 않는 RP 범위 운영 값으로 보고 backend DB의 `rp_user_metadata.metadata` JSONB read model에 별도 저장한다. ## Tenant Custom Field Tenant schema field는 다음 속성을 기준으로 한다. ```json { "key": "employeeNo", "label": "사번", "type": "text", "required": false, "indexed": true, "isLoginId": true, "adminOnly": false, "validation": "^[A-Z0-9]+$" } ``` - `indexed=true`는 검색/필터 최적화 대상이라는 의미다. - `isLoginId=true`이면 backend와 adminfront 모두 `indexed=true`를 강제한다. - `isLoginId=true`는 값 필수를 의미하지 않는다. 값 필수 여부는 `required=true`로 별도 제어한다. - `isLoginId=true`인 field는 `type=text`만 허용한다. - JSONB 통합 정책에서는 `varchar` 크기 지정 의미를 두지 않는다. ## RP Custom Field RP custom schema는 client metadata의 `customUserSchema`에 저장한다. ```json { "customUserSchema": [ { "key": "approvalLevel", "label": "승인 등급", "type": "text", "required": false, "indexed": true, "claimEnabled": true } ] } ``` RP custom value는 `rp_user_metadata` 테이블에 저장한다. 이 테이블은 Ory SSOT를 대체하지 않는 RP 범위 read model이며, Kratos traits나 token claim output을 원장으로 취급하지 않는다. ```text client_id text user_id uuid metadata jsonb created_at timestamptz updated_at timestamptz primary key (client_id, user_id) foreign key (user_id) references users(id) ``` Backend API 초안: ```text GET /api/v1/dev/clients/:id/users/:userId/metadata PUT /api/v1/dev/clients/:id/users/:userId/metadata ``` PUT payload: ```json { "metadata": { "approvalLevel": "A", "preferences": { "theme": "dark" } } } ``` ## 검색 및 인덱스 - `indexed=true` field만 검색 UI/API 후보로 노출한다. - 기본 검색은 exact match, exists, JSON containment 중심으로 제한한다. - RP custom field의 LIKE/fuzzy 검색은 기본 제공하지 않는다. - GIN 인덱스는 backend index manager가 별도 상태로 관리하는 방향을 원칙으로 한다. - API 요청 처리 중 `CREATE INDEX`를 동기 실행하지 않는다. ## Claim Assembly JWT 또는 userinfo 응답에서는 custom field를 top-level에 풀지 않는다. Tenant/RP 단위로 묶어서 전달한다. ```json { "tenant_profiles": [ { "tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210", "tenant_slug": "hanmac-family", "fields": { "employeeNo": "E1001" } } ] } ``` - `claimEnabled=true` field만 RP claim 후보로 포함한다. - 긴 JSON 값은 기본적으로 token claim보다 userinfo/profile API 응답에 싣는 방향을 우선한다. ## 한맥가족 Tenant Claim Assembly 한맥가족(`hanmac-family`) subtree의 tenant claim은 기본 claim과 상세 claim으로 나눈다. 기본 claim은 대표소속 tenant UUID인 `tenant_id`와 전체 소속 목록인 `joined_tenants`이며, RP가 `tenant` claim을 요청하면 tenant별 map 안에 조직 소속 정보를 묶어서 전달한다. 이 정보는 RP가 tenant context를 표시하거나 조직별 기본값을 선택하기 위한 claim assembly 결과다. 관계/권한 판단은 Ory Keto를 기준으로 하고, Ory에 저장되지 않거나 조회가 불가능한 표시/검색 metadata만 Backend read model에서 보강한다. 기본 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`를 기준으로 claim assembly 항목을 만들 수 있다. - 멀티 소속이면 기본 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`를 조합한다.