# 외부 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합니다.

```mermaid
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 구조는 다음과 같습니다.

```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, 직급, 직무, 직책은 Ory Keto 관계와 Backend read model을 조합해 제공합니다. Kratos traits는 인증 식별 정보 중심으로 유지해야 하며, Backend DB metadata나 token claim output도 관계형 데이터의 영구 SSOT로 취급하지 않습니다.
- 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를 제공하지 않은 것이므로 요청을 거부해야 합니다.

```mermaid
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_id`
- `relation`
- `client_id`
- `subject`
- `decision`

따라서 “audit 누락 없음”은 Baron-mediated IAM command에 대해 보장합니다. RP 내부에서 직접 발생하는 비즈니스 이벤트까지 포함하려면 RP가 이 audit contract를 구현하고, audit 저장 실패 시 동일하게 fail closed 처리해야 합니다.
