# Identity Redis Mirror 정책

관련 이슈: #1039

## 결정

사용자 identity에 대해 Backend DB 복제본이나 claim output을 SSOT 일치 보장 대상으로 취급하지 않습니다.

Baron SSO의 identity 원장은 Ory Kratos입니다. Redis는 Kratos identity를 빠르게 조회하기 위한 mirror/cache 계층이고, Backend DB는 Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 데이터의 read model로만 사용합니다.

Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend나 외부 API가 직접 소비하지 않습니다. Backend가 Redis와 허용된 read model을 조합해 cursor 기반 API로 제공합니다.

## 역할 분리

| 구성요소 | 역할 | 보장 |
| --- | --- | --- |
| Kratos `identities` | identity SSOT | 인증 주체, credentials, recovery/verifiable address의 원장 |
| Redis identity mirror | cache/read mirror | 빠른 목록/검색/단건 조회. stale 가능 |
| Backend DB read model | Ory 보완 read model | Ory에 저장되지 않거나 조회 불가능한 업무/운영 데이터 |

Backend DB read model의 visible count를 Kratos identity total로 표시하지 않습니다. 화면과 API에서는 identity mirror count와 허용된 read model count를 분리해서 보여야 합니다.

## 현재 필드 대조

현재 코드 기준으로 Kratos traits와 backend DB `users`는 일부 필드를 중복 보관합니다. Redis mirror 전환 이후에는 Kratos traits를 인증/기본 identity 필드 중심으로 줄이고, Baron 업무/조직/연동 정보는 Ory에 저장되지 않거나 조회가 불가능한 경우에만 Backend read model로 유지합니다.

### Kratos에 유지할 identity 필드

| 필드 | 현재 저장 위치 | 유지 이유 |
| --- | --- | --- |
| `id` | Kratos identity ID, backend `users.id` 참조 | 인증 subject. WORKS `externalKey` 기준 |
| `email` | Kratos traits, backend `users.email` | 로그인 ID, recovery, verification |
| `phone_number` | Kratos traits, backend `users.phone` | SMS 로그인/검증 식별자 |
| `custom_login_ids` | Kratos traits, backend `user_login_ids` | password identifier. backend는 중복/정책 검증용 index |
| `name` | Kratos traits, backend `users.name` | 기본 프로필 표시 |
| `role` | Kratos traits, backend `users.role` | 세션/profile claim 계산에 필요. 최종 권한은 Keto/ReBAC와 함께 검증 |
| `state` | Kratos identity state, backend `users.status`로 일부 투영 | identity 활성 상태의 원본 |

### 현재 Kratos에도 있지만 backend-only로 축소해야 하는 필드

| 필드 | 현재 코드 사용 | 전환 방향 |
| --- | --- | --- |
| `tenant_id` | 대표 테넌트, profile, local user sync | Keto relation과 Backend read model 기준으로 이동. Kratos에는 identity 원장 필드로 추가하지 않음 |
| `department` | 사용자 표시, 조직도, WORKS 비교 | backend `users.department` 또는 tenant membership metadata 기준 |
| `grade` | 직급 표시, role fallback legacy | backend `users.grade`. role fallback 용도 제거 |
| `position` | 직책 표시 | backend `users.position` |
| `jobTitle` | 직무 표시 | backend `users.job_title` |
| `affiliationType` | 내부/외부/게스트 구분 | backend `users.affiliation_type` |
| `relying_party_id` | RP admin profile 보조 | backend/RP relation 기준 |
| `additionalAppointments` | 다중 소속 표시/WORKS 연동 | Keto relation과 Backend read model 기준 |
| `sub_email`, `aliasEmails`, `secondary_emails`, `worksmobileAliasEmails` | WORKS alias 및 보조 이메일 | backend `users.metadata` 또는 명시 테이블 기준 |
| tenant UUID namespaced metadata | tenant별 custom schema 값 | backend `users.metadata` 또는 전용 custom-field storage 기준 |

### Backend read model로만 허용하는 정보

| 데이터 | 저장 위치 | Ory SSOT와의 관계 |
| --- | --- | --- |
| soft-delete 상태 | `users.deleted_at` | Ory Kratos identity 삭제/비활성화와 의미가 다른 Baron 운영 상태 |
| Baron 사용자 상태 세부값 | `users.status` | WORKS provision/deprovision, org visible 정책과 결합된 운영 데이터 |
| WORKS mapping/outbox/job 상태 | `worksmobile_*` 테이블 | 외부 SaaS 연동 상태이며 identity 원장이 아님 |
| Keto outbox 및 relation sync 상태 | `keto_outboxes`, Keto | 권한/관계 원장은 Keto이고 DB는 처리 상태 read model |
| RP metadata/consent/usage | `rp_user_metadata`, `client_consents`, usage tables | Ory에 저장되지 않거나 client 단위 조회가 불가능한 RP 업무 데이터 |
| tenant tree 표시/검색 metadata | `tenants`, relation/outbox | 관계 판단은 Keto, 표시/검색/slug 조회는 Backend read model |
| custom field schema 및 tenant별 값 | tenant config, `users.metadata`, related tables | Ory에 schema/검색 정책을 저장하거나 조회할 수 없는 tenant별 운영 데이터 |
| `user_login_ids` row metadata | `user_login_ids` | Kratos는 identifier 값 원장, 발급 tenant/field key는 Backend 검증용 read model |
| audit/session activity read model | audit/clickhouse/local tables | 감사/운영 분석 데이터 |

정리하면, Kratos에는 “로그인과 subject 확인에 필요한 최소 identity”만 남깁니다. 조직도/WORKS/RP/Keto/감사/tenant custom schema에 필요한 데이터도 Ory에 저장되거나 조회 가능한 경우에는 Ory를 기준으로 하고, 그렇지 않은 영역만 Backend read model을 허용합니다.

## 일관성 모델

Redis mirror는 strong consistency 원장이 아닙니다.

다만 Baron backend를 통해 발생한 Kratos write에 대해서는 다음 수준을 목표로 합니다.

1. Kratos create/update/delete 성공
2. 성공한 identity ID를 기준으로 Kratos `GetIdentity(id)` 재조회
3. Redis `identity:mirror:{id}` write-through
4. Redis index/state 갱신
5. Redis 갱신 실패 시 mirror state를 `failed` 또는 `stale`로 표시

Kratos Admin API를 backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 금지할 수 없는 환경에서는 Redis mirror가 stale해질 수 있음을 인정하고, 주기 refresh와 drift report로만 복구합니다.

## Kratos write 경로 감사

2026-06-09 기준으로 `admin/identities`, `CreateUser`, `UpdateIdentity`, `DeleteIdentity`, `UpdateUserPassword`, Kratos DB `identities` 직접 변경 경로를 정적 검색했습니다.

### 중앙 Kratos client 구현

| 파일 | 역할 | 판정 |
| --- | --- | --- |
| `backend/internal/service/identity_write_service.go` | Kratos identity 변경의 중앙 write boundary. 성공/실패 후 Redis mirror 상태를 갱신 또는 stale 표시 | 허용. 신규 identity write는 이 서비스를 거쳐야 함 |
| `backend/internal/service/kratos_admin_service.go` | Kratos Admin API list/get/create/update/delete/password/session client | 허용. 이후 `IdentityWriteService`의 하위 client로만 사용 |
| `backend/internal/service/ory_service.go` | legacy IDP provider. create/password/verifiable address 변경 시 Kratos Admin API 호출 | 허용하되 write-through 책임은 상위 `IdentityWriteService`로 이동 |

### 정상 backend API 경로

아래 경로는 사용자 요청이 backend를 통과하지만, Redis mirror write-through 구현 시 모두 같은 중앙 write service를 지나야 합니다.

| 경로 | 파일 | Kratos 변경 | 판정 |
| --- | --- | --- | --- |
| admin 사용자 단건 생성 | `backend/internal/handler/user_handler.go` | `OryProvider.CreateUser` | 허용. 생성 성공 후 Kratos 재조회와 Redis mirror write-through 필요 |
| admin 사용자 bulk 생성 | `backend/internal/handler/user_handler.go` | `OryProvider.CreateUser` | 허용. 부분 성공/실패별 mirror 갱신 필요 |
| admin 사용자 수정 | `backend/internal/handler/user_handler.go` | `KratosAdmin.UpdateIdentity`, 선택적 `OryProvider.UpdateUserPassword` | 허용. password 변경도 identity write audit에 포함 |
| admin 사용자 삭제/bulk 삭제 | `backend/internal/handler/user_handler.go` | `KratosAdmin.DeleteIdentity` | 허용. Redis mirror delete 또는 tombstone 갱신 필요 |
| 일반 회원가입 | `backend/internal/handler/auth_handler.go` | `IdpProvider.CreateUser` | 허용이지만 Backend read model sync가 goroutine 기반이라 write-through 기준에서는 약함 |
| 내 프로필 수정 | `backend/internal/handler/auth_handler.go` | `KratosAdmin.UpdateIdentity` | 직접 `PUT /admin/identities/{id}` 호출 제거 완료. 향후 `IdentityWriteService` write-through 대상 |
| 비밀번호 재설정/내 비밀번호 변경 | `backend/internal/handler/auth_handler.go` | `IdpProvider.UpdateUserPassword` | 허용. traits mirror와 별도 audit event 필요 |
| 조직 그룹 멤버 추가 | `backend/internal/service/user_group_service.go` | Kratos write 없음 | Kratos `tenant_id`, `department` write 제거 완료. 조직/부서 정보는 backend DB/Keto/WORKS 기준 |

### backend 내부이지만 일반 API가 아닌 경로

| 경로 | 파일 | Kratos 변경 | 판정 |
| --- | --- | --- | --- |
| super-admin 보장 CLI | `backend/cmd/adminctl/main.go`, `backend/internal/bootstrap/admin_account.go` | `CreateUser`, `UpdateIdentityPassword` | 운영 bootstrap/정비 경로. 실행 후 Redis mirror 갱신 또는 refresh 필수 |
| 초기 admin seed | `backend/internal/bootstrap/kratos_seed.go` | `IdpProvider.CreateUser` | startup bootstrap 경로. 신규 환경에서만 허용하고 반복 실행 영향 점검 필요 |
| role 보정 CLI | `backend/cmd/fix_kratos_roles.go` | `ListIdentities` 후 `UpdateIdentity` | 기본 dry-run. 실제 변경은 `--dry-run=false --maintenance-window --mark-mirror-stale` 없이는 거부 |
| WORKS 기준 Baron 보정 CLI | `backend/cmd/adminctl/worksmobile_sync.go` | `IdentityWriteService.UpdateIdentity` | 중앙 write boundary 강제. 변경 후 Redis mirror stale 표시 |
| RP custom claim traits sync | `backend/internal/handler/dev_handler.go` | `IdentityWriteService.UpdateIdentity` | 중앙 write boundary 강제. RP read model과 Kratos traits 동기화 잔여 경로는 Ory SSOT 전환 대상 |

### backend와 Kratos Admin API를 모두 우회하는 경로

| 경로 | 파일/문서 | 변경 대상 | 판정 |
| --- | --- | --- | --- |
| orphan tenant membership shell 정리 | `scripts/clear_orphan_tenant_memberships.sh` | `ory_kratos.identities.traits` 직접 `UPDATE` | `CONFIRM_KRATOS_DB_MAINTENANCE=baron-sso`와 `MARK_IDENTITY_MIRROR_STALE=true` 없이는 거부 |
| tenant maintenance 문서의 직접 SQL 절차 | `docs/tenant-maintenance-procedures.md` | Baron DB 및 Kratos traits | 문서화된 우회 절차. Kratos DB 직접 UPDATE 절차는 폐기 또는 강한 경고 필요 |
| backup restore | `scripts/backup/restore.sh` | `ory_kratos` 전체 restore 가능 | DR 경로로만 허용. restore 이후 Redis mirror full rebuild와 Baron DB/Kratos drift report 필수 |
| Docker network 직접 접근 | `compose`, `docker`, `mcp` 설정 | `http://kratos:4434` 접근 가능 컨테이너 | public publish는 아니지만 같은 network의 정비 컨테이너가 admin API를 칠 수 있음. 접근 주체 제한 필요 |

### 조치 원칙

- Kratos identity write는 `IdentityWriteService` 하나로 모으고, 성공한 create/update/delete/password 변경이 audit와 Redis mirror write-through를 남기게 합니다.
- `auth_handler.updateKratosIdentity`처럼 `KRATOS_ADMIN_URL`을 직접 읽어 `admin/identities`를 호출하는 코드는 금지합니다.
- `backend/cmd/fix_kratos_roles.go`와 Kratos DB 직접 UPDATE 스크립트는 `--dry-run`, `--maintenance-window`, `--mark-mirror-stale` 같은 명시적 가드 없이는 실행하지 못하게 합니다.
- shell/SQL로 Kratos DB를 직접 수정한 경우에는 Backend read model이나 Redis mirror를 신뢰하지 않고, Kratos full refresh와 drift report를 먼저 실행합니다.
- CI에 정적 정책 테스트를 추가해 `admin/identities` write 호출과 `UPDATE identities` SQL이 허용 파일 밖에 생기면 실패시킵니다.

## Redis 키 설계

초기 구현 기준:

- `identity:mirror:{identityID}`
  - Kratos identity summary JSON
  - 단건 조회 cache
- `identity:mirror:state`
  - `status`: `ready`, `refreshing`, `stale`, `failed`
  - `lastRefreshedAt`
  - `lastError`
  - `observedCount`
- `identity:index:active`
  - active identity ID 목록
  - 구현은 Redis Set 또는 Sorted Set으로 둡니다.

기존 `domain.RedisRepository`가 문자열 KV만 제공하므로 목록 index를 제대로 구현하려면 Redis repository 인터페이스를 확장해야 합니다.

## API 원칙

사용자 목록 API는 다음 값을 구분해야 합니다.

- `identityTotal`: Redis mirror 기준 Kratos identity 수
- `localUserTotal`: PostgreSQL `users` 기준 Baron 로컬 사용자 수
- `mirrorStatus`: Redis mirror 상태
- `items`: identity mirror와 허용된 Backend read model을 조합한 응답

Redis cache miss 발생 시:

1. 단건 조회는 Kratos `GetIdentity(id)`로 fallback합니다.
2. fallback 성공 시 Redis mirror를 갱신합니다.
3. fallback 실패 시 SSOT 조회 실패로 응답합니다.

목록 조회는 Redis mirror가 `ready`가 아니면 경고 상태를 포함해야 합니다. Backend read model을 대체 SSOT처럼 사용하지 않습니다.

## Front 전송과 cursor 보장

front/API로 전달되는 사용자 목록은 Backend가 제공하는 cursor 기반을 원칙으로 합니다. offset은 하위 호환 파라미터로만 유지하고, 신규 화면 또는 대량 조회 화면은 cursor 외 방식을 사용하지 않습니다.

### API 계약

`GET /api/v1/admin/users` 응답은 다음 형태를 유지해야 합니다.

```json
{
  "items": [],
  "limit": 50,
  "cursor": "현재 요청 cursor 또는 빈 값",
  "nextCursor": "다음 페이지 cursor 또는 빈 값",
  "identityTotal": 2106,
  "localUserTotal": 2106,
  "mirrorStatus": "ready"
}
```

전환 기간에는 기존 `total`을 유지할 수 있지만 의미를 명확히 해야 합니다.

- `identityTotal`: Redis identity mirror 기준 Kratos identity 수
- `localUserTotal`: PostgreSQL `users` 기준 Baron business local record 수
- `total`: deprecated. 화면에서 신규 의미로 사용하지 않습니다.

### cursor 불변 조건

- `nextCursor`가 있으면 front는 반드시 다음 요청에 `cursor=nextCursor`를 사용합니다.
- cursor 요청에서는 `offset`을 의미 있게 사용하지 않습니다.
- backend는 cursor 요청에서 안정 정렬 키를 사용해야 합니다. 기본 정렬은 `created_at DESC, id DESC` 또는 mirror index score + `id` 같이 중복 없는 조합이어야 합니다.
- cursor는 필터 조건(`search`, `tenantSlug`, status filter 등)과 묶여야 합니다. 다른 필터에 재사용하면 400으로 거부할 수 있습니다.
- 대량 화면도 `limit=5000&offset=0` 단일 호출을 사용하지 않습니다.

### 현재 front 점검 결과

| 화면/모듈 | 현재 상태 | 조치 |
| --- | --- | --- |
| `adminfront` 사용자 목록 | `useInfiniteQuery`로 `nextCursor` 사용 | 유지 |
| `adminfront` 일부 tenant/user group modal | `fetchUsers(20/100/1000, 0)` 단일 호출 | cursor helper로 전환 |
| `adminfront` bulk upload modal | `fetchUsers(10000, 0)` 단일 호출 | 금지. cursor 수집 helper 또는 서버 검증 API로 전환 |
| `orgfront` 조직도 | Redis orgchart snapshot 기반 | 유지. Backend가 Ory/Redis/read model을 조합해 제공 |
| `orgfront` 조직 picker | Redis orgchart snapshot 기반으로 전환 | 유지. 인증 picker는 `fetchOrgChartSnapshot`, public picker는 token 기반 orgchart API 사용 |
| `orgfront/src/lib/adminApi.ts` | `UserListResponse`에 `nextCursor` 없음 | 타입 계약 보완 |

공통 helper 원칙:

- `fetchUsersPage(params)`는 단일 페이지 조회만 담당합니다.
- `fetchAllUsersByCursor(params)`는 `nextCursor`가 없어질 때까지 반복합니다.
- front에서 arbitrary large limit로 전체 데이터를 가져오는 코드를 금지합니다.
- E2E 또는 unit test에서 `fetchAllUsersByCursor`가 `nextCursor`를 끝까지 따라가는지 검증합니다.

## Refresh 정책

주기 refresh는 Redis mirror를 재구성하는 best-effort 작업입니다.

Kratos list pagination을 끝까지 순회하는 것은 refresh의 필요조건일 뿐입니다. 호출 도중 Kratos identity 변경이 끼어들 수 있으므로 snapshot isolation 보장으로 해석하지 않습니다.

refresh 중 불일치 또는 실패가 발생하면:

- Redis mirror state를 `failed` 또는 `stale`로 표시합니다.
- PostgreSQL `users`를 자동 삭제하지 않습니다.
- drift report를 남겨 운영자가 확인할 수 있게 합니다.

## 금지 사항

- Kratos partial list를 full snapshot으로 간주하지 않습니다.
- Backend read model을 Kratos identity total의 원장으로 사용하지 않습니다.
- Redis mirror refresh 실패를 숨기고 `ready`로 표시하지 않습니다.
- 외부 도구가 Kratos Admin API를 직접 수정하도록 허용하지 않습니다.

## 전환 작업

1. legacy user sync 명칭과 API를 `identity_mirror` 성격으로 분리합니다.
2. Redis repository에 Set/Sorted Set 또는 scan 가능한 index 연산을 추가합니다.
3. Kratos create/update/delete 성공 직후 Redis write-through 테스트를 추가합니다.
4. admin 사용자 목록 응답에서 identity count와 local user count를 분리합니다.
5. 기존 Backend DB count를 identity count처럼 사용하는 화면과 WORKS 비교 경로를 점검합니다.
6. Kratos identity 변경은 `IdentityWriteService` 경유를 강제하고, 직접 `KratosAdmin.UpdateIdentity` 경로를 정책 테스트로 차단합니다.
