14 KiB
외부 RP Ory IAM 연동 가이드 초안
본 문서는 외부 RP가 자체 IAM을 만들지 않고 Baron SSO/Ory Stack/Keto 기반 공용 IAM을 연동하기 위한 초안입니다.
공개 Manifest
- HTML:
/.well-known/baron-rp-manifest - JSON:
/.well-known/baron-rp-manifest.json - JSON Schema:
/.well-known/baron-rp-manifest.schema.json
RP는 JSON manifest를 우선 기준으로 삼고, HTML 페이지는 사람이 확인하는 규격 문서로 사용합니다.
Identity Contract
RP는 raw kratos_identity_id를 비즈니스 키로 저장하거나 파싱하지 않습니다. X-Baron-External-Key는 RP가 생성하거나 제출하는 값이 아니라, Baron이 인증된 subject를 기준으로 발급 또는 조회해서 RP 요청 직전에 주입하는 Baron-issued alias입니다.
X-Baron-Subject: Keto 권한 판정 subject입니다. 예:User:<baron_identity_id>X-Baron-External-Key: RP의 local user insert/upsert에 쓰는 opaque external key입니다. RP는 이 값을 해석하지 않고 전체 문자열 그대로 저장합니다.X-Baron-Client-ID: 현재 요청이 속한 RP client id입니다.
RP의 local user key는 provider + external_key 조합으로 저장합니다. 이메일은 변경될 수 있으므로 stable primary key로 사용하지 않습니다.
정리하면 “RP가 알고 저장할 수 있는 값”은 Baron이 주입한 canonical external alias뿐입니다. RP가 alias를 직접 만들거나 raw kratos_identity_id에서 alias를 계산하면 안 됩니다. 최초 로그인 또는 최초 접근 시 RP가 사용자를 생성해야 한다면, Baron이 이미 주입한 X-Baron-External-Key를 사용해 insert/upsert합니다.
flowchart TD
A[User authenticates through Baron SSO] --> B[Baron resolves internal identity]
B --> C[Baron derives or loads Baron-issued alias]
C --> D[Baron injects X-Baron-External-Key]
D --> E[Baron injects X-Baron-Subject]
E --> I[RP receives trusted headers from Baron gateway]
I --> F[RP upserts local user with provider + X-Baron-External-Key]
F --> G[RP stores the full external key as opaque value]
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 구조는 다음과 같습니다.
{
"tenant_id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"joined_tenants": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"01970f0b-3448-7bb8-bdc7-16b6a1d2e661"
]
}
tenant claim을 요청하면 상세 claim 구조는 다음과 같습니다.
{
"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:tenantclaim 요청 시 포함됩니다.lead=true로 판정된 tenant id 목록입니다.tenants:tenantclaim 요청 시 포함됩니다. 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-familyroot까지의 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-familyroot에 도달한 경우에만 한맥가족 확장 필드를 보강합니다.additionalAppointments만 존재하고 tenant별 namespaced traits map이 없어도tenant_id또는additionalAppointments[].tenantId를 기준으로tenants항목을 생성할 수 있습니다.- 사용자가 여러 tenant에 소속되면 기본 claim인
joined_tenants에는 모든 소속 tenant가 포함됩니다. tenantclaim 요청 시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와 다릅니다.
{
"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를 제공하지 않은 것이므로 요청을 거부해야 합니다.
flowchart TD
A[RP request] --> B{obj_id supplied?}
B -->|yes| C[Normalize object type and obj_id]
B -->|no| D{Route has client_id?}
D -->|yes| E[obj_id = RelyingParty:<client_id>]
D -->|no| F{Route has tenant_id?}
F -->|yes| G[obj_id = Tenant:<tenant_id>]
F -->|no| H[Reject: explicit obj_id required]
C --> I[Check Keto relation]
E --> I
G --> I
I --> J{allowed?}
J -->|yes| K[Inject trusted Baron headers]
J -->|no| L[Reject request]
K --> M[Write audit with obj_id, relation, client_id, X-Request-Id]
대표 object 패턴은 다음과 같습니다.
- RP 단위:
RelyingParty:<client_id> - Tenant 단위:
Tenant:<tenant_id> - RP 내부 리소스 단위:
Resource:<resource_type>:<resource_id>
Audit Contract
audit 누락 방지는 범위를 나눠서 보장합니다.
- Baron이 중개하는 IAM mutation은
fail_closed_sync입니다. audit write가 실패하면 원 요청도 실패해야 합니다. - audit sink가 없거나 사용할 수 없으면 mutation은
reject_mutation으로 처리합니다. - allowlist된 read audit은 부하 보호를 위해 best effort로 둘 수 있으나, 권한/설정 변경 command에는 적용하지 않습니다.
- RP 자체 비즈니스 이벤트는 RP가 동일한
X-Request-Id를 correlation key로 사용해 audit을 남겨야 합니다.
필수 audit detail 필드는 다음과 같습니다.
obj_idrelationclient_idsubjectdecision
따라서 “audit 누락 없음”은 Baron-mediated IAM command에 대해 보장합니다. RP 내부에서 직접 발생하는 비즈니스 이벤트까지 포함하려면 RP가 이 audit contract를 구현하고, audit 저장 실패 시 동일하게 fail closed 처리해야 합니다.