Files
baron_qa_write/docs/sso_guides/integration-directory-user-create-api-guide.md
T
root c87e0aa0c1
Deploy EG-BIM QA Gateway / deploy (push) Successful in 56s
fix: 맵핑 관련 오류 로그 셋팅
2026-10-30 12:38:25 +09:00

448 lines
26 KiB
Markdown

# Directory API 외부 사용자 계정 생성 이용 가이드
이 문서는 외부 시스템이 Baron SSO의 Directory API를 사용해 외부 사용자 계정을 한 명씩 생성하는 방법을 설명합니다. API와 필드 이름은 OpenAPI 계약의 영문 표기를 그대로 사용합니다.
> Staging Swagger UI: [https://sso.hmac.kr/api/docs](https://sso.hmac.kr/api/docs)
>
> Staging OpenAPI: [https://sso.hmac.kr/api/openapi.yaml](https://sso.hmac.kr/api/openapi.yaml)
## Staging Swagger 화면
2026-08-26 기준으로 staging Swagger UI와 OpenAPI endpoint가 모두 HTTP `200`을 반환하고, `POST /api/v1/integrations/directory/users`가 노출되는 것을 확인했습니다. 아래 화면은 해당 사용자 생성 항목을 펼친 실제 staging Swagger UI입니다.
![Staging Swagger UI의 Directory 외부 사용자 생성 API](../snapshots/staging-swagger-directory-user-create-2026-08-26.png)
Swagger UI의 **Try it out**은 실제 staging 요청을 전송합니다. 승인된 테스트 Tenant와 테스트용 Integration credential을 준비한 경우에만 사용하고, 실제 Secret이나 PII가 캡처·로그·issue에 남지 않도록 주의합니다.
## 1. 적용 범위
사용자 생성 API는 다음 endpoint입니다.
```http
POST /api/v1/integrations/directory/users
```
이 endpoint는 외부 시스템용 기계 자격증명인 Integration credential로만 호출합니다. AdminFront 로그인 session이나 `Authorization: Bearer ...`를 Directory API 호출에 섞지 않습니다.
계정 생성 시 서버는 다음 identity 속성을 고정합니다.
| 속성 | 서버 고정값 | 호출자가 지정할 수 있는지 |
| --- | --- | --- |
| `role` | `user` | 아니요 |
| `status` | `active` | 아니요 |
| `affiliationType` | `external` | 아니요 |
| 대표 Tenant | 요청의 `primaryTenantId` | `primaryTenantId`로만 지정 |
`role`, `status`, `affiliationType`은 생성 응답에 모두 노출되는 필드가 아닙니다. 생성 후 관리자 조회로 최종 identity를 확인합니다.
근거: [OpenAPI Directory 계약](../../backend/docs/openapi.yaml), [Directory handler](../../backend/internal/handler/directory_integration_handler.go), [Directory handler 테스트](../../backend/internal/handler/directory_integration_handler_test.go)
## 2. 보안 경계와 사전 조건
호출 전에 다음 조건을 충족해야 합니다.
1. 대상 Tenant의 UUID를 확인합니다. `primaryTenantId`는 Tenant slug가 아닌 UUID여야 합니다.
2. 대상 Tenant를 포함하는 `tenant_subtree` scope의 `directory.user.create` credential을 발급합니다.
3. 대상 Tenant는 credential의 scope root 또는 그 descendant여야 합니다.
4. `hanmac-family`와 그 전체 subtree는 Directory 연동 대상이 아닙니다.
5. 요청마다 비어 있지 않은 새 `Idempotency-Key`를 준비합니다.
Integration credential은 interactive 관리자 권한이 아닙니다.
- `/api/v1/admin/*`를 호출할 수 없습니다.
- Admin session·Bearer token과 Integration credential header를 한 요청에 혼합할 수 없습니다.
- credential 하나에는 현재 정확히 하나의 OperationID만 부여합니다.
- Secret은 발급·교체·재발급 응답에서 한 번만 표시됩니다.
- 만료, revoke 또는 rotate로 무효화된 Secret은 인증에 사용할 수 없습니다.
- 요청 body의 `email`, `name`, `phone`은 Directory 감사 세부정보에 저장하지 않습니다. 호출 측에서도 Secret과 PII를 URL, application log, APM 또는 issue에 기록하지 마십시오.
근거: [Integration credential domain](../../backend/internal/domain/integration_credential.go), [Integration credential 인증 middleware](../../backend/internal/middleware/integration_credential_auth.go), [Integration Credential 및 Directory API 정책](https://gitea.hmac.kr/baron/baron-sso/wiki/Integration-Credential-%EB%B0%8F-Directory-API-%EC%A0%95%EC%B1%85)
## 3. Integration credential 발급
### 3.1 AdminFront에서 발급
fresh identity의 raw role이 exact `super_admin`인 관리자가 다음 순서로 발급합니다.
1. AdminFront의 `/api-keys`에서 **API 키 관리**를 엽니다.
2. **Directory 연동** 탭을 선택합니다.
3. 이름과 발급 사유를 입력합니다.
4. Operation에서 **외부 사용자 생성**(`directory.user.create`)을 선택합니다.
5. **Scope root Tenant**를 선택합니다. Directory credential의 scope는 항상 `tenant_subtree`입니다.
6. 유효기간을 `30`, `90`, `180`, `369`일 또는 `만료 없음` 중에서 선택합니다. 기본값은 90일입니다.
7. **발급하고 Secret 확인**을 선택한 뒤 `clientId`와 `clientSecret`을 승인된 Secret 저장소에 즉시 저장합니다.
창을 닫으면 `clientSecret`을 다시 조회할 수 없습니다. 분실했을 때는 기존 Secret을 복구하지 말고 rotate하여 새 Secret으로 교체합니다.
근거: [AdminFront API 키 관리 화면](../../adminfront/src/features/api-keys/ApiKeyManagementPage.tsx), [AdminFront 관리 API client](../../adminfront/src/lib/adminApi.ts)
### 3.2 관리 API에서 발급
관리 API를 이용할 때도 exact `super_admin`의 interactive access token이 필요합니다. 아래 값은 모두 예시 placeholder이며 실제 token, Secret 또는 운영 Tenant UUID를 문서나 shell history에 저장하지 마십시오.
```bash
BASE_URL='https://sso.hmac.kr'
ADMIN_ACCESS_TOKEN='<SUPER_ADMIN_ACCESS_TOKEN>'
SCOPE_TENANT_ID='11111111-1111-4111-8111-111111111111'
curl --fail-with-body --silent --show-error \
--request POST \
"${BASE_URL}/api/v1/admin/integration-credentials" \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}" \
--header 'Content-Type: application/json' \
--data "{
\"name\": \"Example HR directory user creator\",
\"operationIds\": [\"directory.user.create\"],
\"scope\": {
\"kind\": \"tenant_subtree\",
\"tenantId\": \"${SCOPE_TENANT_ID}\"
},
\"exposure\": \"structure\",
\"expiresInDays\": 90,
\"reason\": \"외부 사용자 계정 연동\"
}"
```
응답 예시는 다음과 같습니다. `clientSecret`은 이 응답에서만 한 번 표시됩니다.
```json
{
"credential": {
"id": "22222222-2222-4222-8222-222222222222",
"name": "Example HR directory user creator",
"clientId": "bic_<EXAMPLE_KEY_ID>",
"operationIds": ["directory.user.create"],
"scopeKind": "tenant_subtree",
"scopeTenantId": "11111111-1111-4111-8111-111111111111",
"exposure": "structure",
"status": "active",
"creationReason": "외부 사용자 계정 연동",
"createdAt": "2026-08-24T00:00:00Z"
},
"clientSecret": "<DISPLAYED_ONCE_CLIENT_SECRET>"
}
```
Directory Operation에서는 `exposure` 입력값과 관계없이 서버가 `structure`로 고정합니다. `global` scope는 허용하지 않습니다. `operationIds`에는 정확히 하나의 OperationID만 넣어야 합니다.
관리 API에서 가능한 credential 작업은 다음과 같습니다.
| 목적 | Method 및 endpoint | 동작 |
| --- | --- | --- |
| 목록 | `GET /api/v1/admin/integration-credentials` | Secret을 제외한 발급 이력을 조회합니다. |
| 신규 발급 | `POST /api/v1/admin/integration-credentials` | 새 credential과 1회 표시 Secret을 발급합니다. |
| Secret 교체 | `POST /api/v1/admin/integration-credentials/{id}/rotate` | 기존 Secret을 즉시 무효화하고 새 Secret을 한 번 반환합니다. |
| 즉시 회수 | `POST /api/v1/admin/integration-credentials/{id}/revoke` | credential을 `revoked`로 변경하고 Secret hash를 제거합니다. |
| legacy 재발급 | `POST /api/v1/admin/integration-credentials/{id}/reissue` | `inactive` legacy 이력만 새 최소 권한과 새 Secret으로 재발급합니다. |
근거: [OpenAPI credential 계약](../../backend/docs/openapi.yaml), [Integration credential 관리 handler](../../backend/internal/handler/integration_credential_handler.go), [server route](../../backend/cmd/server/main.go)
## 4. 사용자 생성 요청
### 4.1 Header
| Header | 필수 | 설명 |
| --- | --- | --- |
| `Content-Type: application/json` | 예 | JSON request body입니다. |
| `X-Baron-Key-ID` | 예 | 발급 응답의 `credential.clientId`입니다. |
| `X-Baron-Key-Secret` | 예 | 발급 응답에서 한 번 표시된 `clientSecret`입니다. |
| `Idempotency-Key` | 예 | 호출자가 생성한 비어 있지 않은 요청 식별자입니다. UUID 사용을 권장합니다. |
### 4.2 허용 필드
요청 body는 strict JSON으로 처리합니다. 다음 필드만 허용됩니다.
| 필드 | 필수 | 형식 | 설명 |
| --- | --- | --- | --- |
| `email` | 예 | 유효한 email address | 앞뒤 공백을 제거하고 lowercase로 정규화합니다. |
| `name` | 예 | 비어 있지 않은 string | 앞뒤 공백을 제거합니다. |
| `primaryTenantId` | 예 | UUID | credential scope 안에 있는 대표 Tenant입니다. |
| `phone` | 아니요 | compact E.164 | `+`와 국가번호를 포함하며 공백·하이픈을 넣지 않습니다. 형식 예: `+<COUNTRY_CODE><NATIONAL_NUMBER>` |
### 4.3 금지 필드
허용 목록에 없는 필드는 `400 invalid_request`로 거부됩니다. 특히 다음 필드를 보내지 마십시오.
- `password`
- `role`
- `status`
- `affiliationType`
- `tenantSlug`
- `metadata`
- `additionalTenantIds`
- `additionalAppointments`
- membership 또는 관계를 직접 표현하는 그 밖의 필드
첫 생성에서는 `primaryTenantId` 하나만 지정합니다. 추가 일반 소속은 사용자 생성 성공 후 별도의 `directory.user.memberships.update` credential과 membership PATCH로 처리합니다.
### 4.4 curl 예시
예시는 예약 domain인 `example.test`와 문서용 UUID만 사용합니다.
```bash
BASE_URL='https://sso.hmac.kr'
BARON_KEY_ID='bic_<EXAMPLE_KEY_ID>'
BARON_KEY_SECRET='<EXAMPLE_KEY_SECRET>'
PRIMARY_TENANT_ID='11111111-1111-4111-8111-111111111111'
IDEMPOTENCY_KEY='33333333-3333-4333-8333-333333333333'
curl --fail-with-body --silent --show-error \
--request POST \
"${BASE_URL}/api/v1/integrations/directory/users" \
--header 'Content-Type: application/json' \
--header "X-Baron-Key-ID: ${BARON_KEY_ID}" \
--header "X-Baron-Key-Secret: ${BARON_KEY_SECRET}" \
--header "Idempotency-Key: ${IDEMPOTENCY_KEY}" \
--data "{
\"email\": \"external.user@example.test\",
\"name\": \"Example External User\",
\"primaryTenantId\": \"${PRIMARY_TENANT_ID}\"
}"
```
위 예시는 PII로 오인될 수 있는 전화번호를 의도적으로 넣지 않았습니다. `phone`이 필요하면 compact E.164 값으로 추가하고, 필요하지 않으면 해당 key 전체를 생략합니다. 빈 문자열을 보내는 방식보다 필드를 생략하는 편이 호출 계약을 명확하게 유지합니다.
근거: [OpenAPI `DirectoryUserCreateRequest`](../../backend/docs/openapi.yaml), [strict JSON 및 입력 검증](../../backend/internal/handler/directory_integration_handler.go)
## 5. 성공 응답과 idempotency
성공 시 HTTP `201 Created`와 다음 형태의 응답을 반환합니다.
```json
{
"userId": "44444444-4444-4444-8444-444444444444",
"email": "external.user@example.test",
"name": "Example External User",
"primaryTenantId": "11111111-1111-4111-8111-111111111111",
"status": "active"
}
```
`userId`는 이후 관리자 조회와 membership PATCH에 사용하는 Baron user UUID입니다. 응답에는 password가 포함되지 않습니다.
`Idempotency-Key`는 다음 원칙으로 관리합니다.
- 하나의 논리적 생성 요청에는 하나의 key를 사용합니다.
- timeout 또는 연결 종료로 결과를 받지 못했을 때만 같은 body와 같은 key로 재시도합니다.
- 같은 credential·같은 Operation·같은 key·같은 payload의 committed 요청은 기존 결과를 replay할 수 있습니다.
- 같은 key를 다른 email, name, phone 또는 `primaryTenantId`에 재사용하지 마십시오. 기존 mutation과 fingerprint가 다르면 idempotency conflict입니다.
- 서버 내부 key namespace에는 credential ID와 OperationID가 포함되므로 서로 다른 credential 또는 Operation의 caller key가 우연히 같아도 같은 mutation으로 취급하지 않습니다.
- HTTP `5xx` 또는 network timeout 뒤 새 key로 즉시 다시 보내면 중복 생성 시도가 될 수 있습니다. 먼저 같은 key로 재시도하거나 관리자 조회로 결과를 확인합니다.
근거: [Directory idempotency namespace](../../backend/internal/handler/directory_integration_handler.go), [durable identity mutation 처리](../../backend/internal/identitywrite/service.go), [identity mutation journal](../../backend/internal/repository/identity_mutation_journal_repository.go)
## 6. 오류 처리
오류 body는 기본적으로 `error`, `code`, 선택적 `details`를 사용합니다.
```json
{
"error": "primaryTenantId must be a UUID",
"code": "invalid_primary_tenant"
}
```
사용자 생성 경로에서 호출자가 주로 처리해야 하는 오류는 다음과 같습니다.
| HTTP | `code` | 의미와 조치 |
| --- | --- | --- |
| `400` | `invalid_request` | 지원하지 않는 필드, malformed JSON 또는 JSON 값이 두 개 이상입니다. body를 허용 필드만 포함하도록 수정합니다. |
| `400` | `idempotency_key_required` | `Idempotency-Key`가 없거나 비어 있습니다. 요청별 key를 지정합니다. |
| `400` | `invalid_email` | `email` 형식이 유효하지 않습니다. |
| `400` | `name_required` | trim 후 `name`이 비어 있습니다. |
| `400` | `primary_tenant_required` | `primaryTenantId`가 없습니다. |
| `400` | `invalid_primary_tenant` | `primaryTenantId`가 UUID가 아닙니다. |
| `400` | `invalid_phone` | `phone`이 compact E.164 형식이 아닙니다. |
| `401` | `integration_credential_required` | `X-Baron-Key-ID` 또는 `X-Baron-Key-Secret`이 없습니다. |
| `401` | `invalid_integration_credential` | key가 틀렸거나 inactive, revoked, rotate된 credential입니다. |
| `401` | `integration_credential_expired` | credential이 만료되었습니다. 새 credential을 발급하고 기존 credential의 revoke 여부를 운영 정책에 따라 결정합니다. rotate는 만료 시각을 연장하지 않습니다. |
| `401` | `credential_conflict` | interactive credential과 Integration credential을 혼합했습니다. Directory 요청에는 Integration credential header만 사용합니다. |
| `403` | `credential_operation_mismatch` | credential이 `directory.user.create`를 부여하지 않습니다. 생성 전용 credential을 사용합니다. |
| `403` | `credential_scope_exceeded` | 대상 Tenant가 credential subtree 밖입니다. 응답 `details.requiredScope`를 확인하고 요청 또는 scope를 수정합니다. |
| `403` | `protected_tenant_scope` | `hanmac-family` 또는 그 subtree를 대상으로 요청했습니다. Directory 연동으로 처리할 수 없습니다. |
| `404` | `tenant_not_found` | `primaryTenantId`에 해당하는 Tenant가 없습니다. |
| `409` | `user_already_exists` | 사용자가 이미 존재합니다. upsert하지 않으므로 기존 사용자와 후속 처리 정책을 확인합니다. |
| `409` | `idempotency_conflict` | 같은 `Idempotency-Key`를 다른 mutation에 사용했습니다. 기존 요청 결과를 조사한 뒤 새 논리 요청에만 새 key를 사용합니다. |
| `503` | `credential_store_unavailable` | credential 저장소를 사용할 수 없습니다. mutation을 새 key로 반복하지 말고 같은 요청 key를 보존합니다. |
| `503` | `audit_unavailable` | mutation 전 필수 audit attempt를 저장할 수 없어 fail-closed했습니다. handler mutation은 실행되지 않습니다. |
| `503` | `credential_usage_record_failed` | 감사 attempt 이후 credential 사용 시각을 안전하게 기록하지 못했습니다. handler mutation 전 실패입니다. |
| `503` | `directory_integration_unavailable` | Tenant 또는 identity write 의존성이 준비되지 않았습니다. |
| `503` | `tenant_graph_unavailable` | 완전한 Tenant graph 또는 보호 root를 확인할 수 없어 fail-closed했습니다. |
| `503` | `directory_mutation_failed` | Directory mutation을 완료하지 못한 일반 오류입니다. 새 key로 반복하지 말고 같은 key를 유지해 재시도하거나 관리자에게 결과 확인을 요청합니다. |
`idempotency_conflict`는 handler가 의도한 오류 계약입니다. 다만 현재 저장소에는 같은 key와 다른 payload를 실제 HTTP로 호출해 `409` mapping까지 확인하는 회귀 테스트가 없습니다. 호출자는 key를 재사용하지 않아야 하며, 이 경우 특정 `code`만 신뢰해 자동 복구하지 말고 모든 non-2xx 응답에서 처리를 중단한 뒤 기존 요청 결과를 확인해야 합니다.
`credential_scope_exceeded`에는 필요한 Operation과 concrete Tenant scope가 포함될 수 있습니다.
```json
{
"error": "directory.user.create requires a credential covering the target tenant subtree",
"code": "credential_scope_exceeded",
"details": {
"requiredOperationId": "directory.user.create",
"requiredScope": {
"kind": "tenant_subtree",
"tenantId": "11111111-1111-4111-8111-111111111111"
}
}
}
```
근거: [Error response schema](../../backend/docs/openapi.yaml), [Directory 오류 분기](../../backend/internal/handler/directory_integration_handler.go), [Integration credential 인증 오류](../../backend/internal/middleware/integration_credential_auth.go)
## 7. 생성 후 확인
`201` 응답의 `userId`, `email`, `primaryTenantId`, `status`를 우선 보관합니다. Integration credential에는 사용자 관리 조회 권한이 없으므로 최종 identity 확인은 권한이 있는 interactive 관리자 경로로 수행합니다.
```bash
BASE_URL='https://sso.hmac.kr'
ADMIN_ACCESS_TOKEN='<AUTHORIZED_ADMIN_ACCESS_TOKEN>'
USER_ID='44444444-4444-4444-8444-444444444444'
curl --fail-with-body --silent --show-error \
"${BASE_URL}/api/v1/admin/users/${USER_ID}" \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}"
```
다음을 확인합니다.
- 응답 `id`가 생성 응답의 `userId`와 같습니다.
- `email`과 `name`이 정규화된 생성 값과 같습니다.
- `role`은 `user`, `status`는 `active`, `affiliationType`은 `external`입니다.
- 대표 Tenant가 요청한 `primaryTenantId`입니다.
identity commit 뒤 projection이 지연될 수 있으므로 관리자 readback이 즉시 보이지 않으면 제한된 간격과 횟수로 다시 조회합니다. 새 `Idempotency-Key`로 사용자 생성을 반복하지 않습니다.
근거: [관리자 user read route](../../backend/cmd/server/user_read_routes.go), [관리자 user 조회 handler](../../backend/internal/handler/user_handler.go), [staging live acceptance](../../test/issue_1878_staging_directory_live_e2e.mjs)
## 8. Password 설정
Directory 사용자 생성 요청과 응답에는 password가 없습니다. caller가 초기 password를 지정하거나 읽을 수 없으며, 서버 내부 identity 생성 절차에서 사용하는 password도 외부에 반환하지 않습니다.
사용자는 기존 password reset 흐름에서 본인의 login identifier로 재설정을 요청하고, 전달받은 일회성 link를 통해 새 password를 직접 설정합니다. email은 생성 필수값이므로 일반적으로 생성한 email을 login ID로 사용합니다.
```http
POST /api/v1/auth/password/reset/initiate
Content-Type: application/json
{
"loginId": "external.user@example.test"
}
```
운영 시스템이 reset token 또는 새 password를 대신 수집해서는 안 됩니다. `dryRun`과 `drySend`는 운영에서 사용할 수 있는 일반 연동 옵션이 아닙니다.
근거: [password reset route](../../backend/cmd/server/main.go), [password reset handler](../../backend/internal/handler/auth_handler.go), [password reset request model](../../backend/internal/domain/auth_models.go), [password 미수신 테스트](../../backend/internal/handler/directory_integration_handler_test.go)
## 9. 추가 소속은 별도 PATCH와 별도 credential 사용
사용자 생성용 `directory.user.create` credential로 membership PATCH를 호출하면 `403 credential_operation_mismatch`가 반환됩니다. 추가 일반 소속에는 `directory.user.memberships.update` 전용 credential을 별도로 발급합니다.
```http
PATCH /api/v1/integrations/directory/users/{userId}/memberships
```
```bash
MEMBERSHIP_KEY_ID='bic_<MEMBERSHIP_KEY_ID>'
MEMBERSHIP_KEY_SECRET='<MEMBERSHIP_KEY_SECRET>'
USER_ID='44444444-4444-4444-8444-444444444444'
ADDITIONAL_TENANT_ID='55555555-5555-4555-8555-555555555555'
MEMBERSHIP_IDEMPOTENCY_KEY='66666666-6666-4666-8666-666666666666'
curl --fail-with-body --silent --show-error \
--request PATCH \
"${BASE_URL}/api/v1/integrations/directory/users/${USER_ID}/memberships" \
--header 'Content-Type: application/json' \
--header "X-Baron-Key-ID: ${MEMBERSHIP_KEY_ID}" \
--header "X-Baron-Key-Secret: ${MEMBERSHIP_KEY_SECRET}" \
--header "Idempotency-Key: ${MEMBERSHIP_IDEMPOTENCY_KEY}" \
--data "{
\"addTenantIds\": [\"${ADDITIONAL_TENANT_ID}\"],
\"removeTenantIds\": []
}"
```
PATCH는 대표 Tenant를 변경하지 않고 일반 소속 delta를 원자적으로 반영합니다. 현재와 결과 소속은 같은 최상위 Tenant root에 속해야 하며, 대표 Tenant 제거와 마지막 소속 제거는 허용하지 않습니다.
근거: [OpenAPI membership 계약](../../backend/docs/openapi.yaml), [membership handler](../../backend/internal/handler/directory_integration_handler.go), [membership 회귀 테스트](../../backend/internal/handler/directory_integration_handler_test.go)
## 10. 테스트 cleanup과 credential revoke
로컬 또는 staging 검증용으로 만든 resource는 검증이 끝난 뒤 생성의 역순으로 정리합니다.
1. membership 검증을 완료합니다.
2. 생성용과 membership용 credential을 각각 revoke해 추가 호출을 먼저 차단합니다.
3. exact `super_admin` 또는 삭제 권한이 있는 interactive 관리자가 임시 사용자를 삭제합니다.
4. 테스트용 Tenant가 있다면 descendant부터 삭제합니다.
5. 관리자 또는 phone-login 검증 session을 종료합니다.
임시 사용자 삭제 예시는 다음과 같습니다. 삭제도 별도의 `Idempotency-Key`를 사용합니다.
```bash
CLEANUP_IDEMPOTENCY_KEY='77777777-7777-4777-8777-777777777777'
curl --fail-with-body --silent --show-error \
--request DELETE \
"${BASE_URL}/api/v1/admin/users/${USER_ID}" \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}" \
--header "Idempotency-Key: ${CLEANUP_IDEMPOTENCY_KEY}"
```
credential revoke 예시는 다음과 같습니다.
```bash
CREDENTIAL_ID='22222222-2222-4222-8222-222222222222'
curl --fail-with-body --silent --show-error \
--request POST \
"${BASE_URL}/api/v1/admin/integration-credentials/${CREDENTIAL_ID}/revoke" \
--header "Authorization: Bearer ${ADMIN_ACCESS_TOKEN}" \
--header 'Content-Type: application/json' \
--data '{"reason":"staging 사용자 생성 검증 종료"}'
```
실제 업무 계정은 테스트 cleanup 대상으로 삭제하지 않습니다. 운영 계정의 비활성화·삭제는 별도 lifecycle 및 승인 정책을 따릅니다. revoke된 credential의 이력은 남지만 해당 Secret은 다시 사용할 수 없습니다.
근거: [credential revoke 구현](../../backend/internal/handler/integration_credential_handler.go), [staging cleanup 구현](../../test/issue_1878_staging_directory_live_e2e.mjs)
## 11. Staging 검증 사실
2026-08-24 staging live acceptance에서 connect phone-login으로 획득한 exact `super_admin` session을 사용해 다음 항목을 검증했습니다.
- `directory.user.create`와 `directory.user.memberships.update` credential을 분리 발급했습니다.
- Directory 사용자 생성이 `201`로 성공하고 관리자 user readback의 ID가 일치했습니다.
- 생성 전용 credential의 membership PATCH는 `403 credential_operation_mismatch`로 차단됐습니다.
- membership 전용 credential로 같은 subtree의 descendant 소속 추가가 `200`으로 성공했습니다.
- 생성 body의 금지 필드 `additionalTenantIds`는 `400 invalid_request`로 거부됐습니다.
- 검증 후 임시 사용자와 Tenant가 조회되지 않고 두 credential이 `revoked` 상태이며 phone session도 제거된 것을 확인했습니다.
이 검증은 staging에서의 계약 검증 사실이며 production 배포 또는 production mutation을 의미하지 않습니다.
근거: [staging live acceptance script](../../test/issue_1878_staging_directory_live_e2e.mjs), [staging assertion](../../test/issue_1878_staging_directory_assertions.mjs), [Integration Credential 및 Directory API 정책](https://gitea.hmac.kr/baron/baron-sso/wiki/Integration-Credential-%EB%B0%8F-Directory-API-%EC%A0%95%EC%B1%85)
## 12. 호출자 운영 체크리스트
- [ ] `directory.user.create` exact Operation credential을 사용합니다.
- [ ] `primaryTenantId`가 credential의 `tenant_subtree` 안에 있습니다.
- [ ] Secret과 PII를 log, URL, issue, Wiki 또는 source code에 남기지 않습니다.
- [ ] 요청 body에는 허용된 네 필드만 사용합니다.
- [ ] 논리 요청별 `Idempotency-Key`를 만들고 retry에서는 같은 key와 같은 body를 유지합니다.
- [ ] `201`의 `userId`를 보관하고 관리자 readback으로 고정 속성을 확인합니다.
- [ ] password는 사용자 본인의 password reset 흐름으로 설정합니다.
- [ ] 추가 소속은 별도 membership credential과 PATCH로 처리합니다.
- [ ] 검증용 resource와 credential은 종료 시 cleanup 및 revoke합니다.
## 13. 1차 출처
- [Backend OpenAPI](../../backend/docs/openapi.yaml)
- [Directory integration handler](../../backend/internal/handler/directory_integration_handler.go)
- [Directory integration handler 테스트](../../backend/internal/handler/directory_integration_handler_test.go)
- [Integration credential 관리 handler](../../backend/internal/handler/integration_credential_handler.go)
- [Integration credential 인증 middleware](../../backend/internal/middleware/integration_credential_auth.go)
- [Integration credential domain](../../backend/internal/domain/integration_credential.go)
- [Identity write service](../../backend/internal/identitywrite/service.go)
- [Identity mutation journal repository](../../backend/internal/repository/identity_mutation_journal_repository.go)
- [AdminFront API 키 관리 화면](../../adminfront/src/features/api-keys/ApiKeyManagementPage.tsx)
- [Staging Directory live acceptance](../../test/issue_1878_staging_directory_live_e2e.mjs)
- [Integration Credential 및 Directory API 정책](https://gitea.hmac.kr/baron/baron-sso/wiki/Integration-Credential-%EB%B0%8F-Directory-API-%EC%A0%95%EC%B1%85)