# Identity Redis Mirror 정책

관련 이슈: #1039

## 결정

사용자 identity에 대해 PostgreSQL DB projection을 SSOT 일치 보장 대상으로 취급하지 않습니다.

Baron SSO의 identity 원장은 Ory Kratos입니다. Redis는 Kratos identity를 빠르게 조회하기 위한 mirror/cache 계층이고, PostgreSQL `users`는 Baron 비즈니스 메타데이터, WORKS/Keto/RP 연동 참조, 감사 가능한 로컬 레코드로만 사용합니다.

## 역할 분리

| 구성요소 | 역할 | 보장 |
| --- | --- | --- |
| Kratos `identities` | identity SSOT | 인증 주체, credentials, recovery/verifiable address의 원장 |
| Redis identity mirror | cache/read mirror | 빠른 목록/검색/단건 조회. stale 가능 |
| PostgreSQL `users` | business local record | tenant, WORKS, RP, Keto 연동에 필요한 Baron 로컬 상태 |

PostgreSQL `users`의 visible count를 Kratos identity total로 표시하지 않습니다. 화면과 API에서는 identity mirror count와 local business user count를 분리해서 보여야 합니다.

## 현재 필드 대조

현재 코드 기준으로 Kratos traits와 backend DB `users`는 일부 필드를 중복 보관합니다. Redis mirror 전환 이후에는 Kratos traits를 인증/기본 identity 필드 중심으로 줄이고, Baron 업무/조직/연동 정보는 backend DB 전용으로 이동하는 방향을 기준으로 합니다.

### 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 | backend `users.tenant_id`와 membership/Keto 기준으로 이동. Kratos에는 최소 claim 캐시로만 허용 |
| `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 연동 | backend membership metadata 또는 `users.metadata` 기준 |
| `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 DB에만 저장되거나 DB가 원장이어야 하는 정보

| 데이터 | 저장 위치 | Kratos에 두지 않는 이유 |
| --- | --- | --- |
| soft-delete 상태 | `users.deleted_at` | Baron 운영/감사 로컬 상태. Kratos identity 삭제/비활성화와 의미가 다름 |
| Baron 사용자 상태 세부값 | `users.status` | WORKS provision/deprovision, org visible 정책과 결합된 업무 상태 |
| WORKS mapping/outbox/job 상태 | `worksmobile_*` 테이블 | 외부 SaaS 연동 상태이며 identity 인증 원장이 아님 |
| Keto outbox 및 relation sync 상태 | `keto_outboxes`, Keto | 권한/관계 원장과 처리 상태 |
| RP metadata/consent/usage | `rp_user_metadata`, `client_consents`, usage tables | RP별 업무 데이터와 위임 상태 |
| tenant tree, slug, visibility, owner/admin | `tenants`, relation/outbox | 조직/권한 원장. Kratos traits에 넣으면 stale claim이 됨 |
| custom field schema 및 tenant별 값 | tenant config, `users.metadata`, related tables | 조회/검색/검증 정책이 tenant별로 달라짐 |
| `user_login_ids` row metadata | `user_login_ids` | Kratos는 identifier 값만 필요. 발급 tenant/field key는 backend 업무 정보 |
| audit/session activity projection | audit/clickhouse/local tables | 감사/운영 분석 데이터 |

정리하면, Kratos에는 “로그인과 subject 확인에 필요한 최소 identity”만 남기고, 조직도/WORKS/RP/Keto/감사/tenant custom schema에 필요한 데이터는 backend DB가 맡습니다.

## 일관성 모델

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/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` | 허용이지만 local DB 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` 없이는 거부 |

### 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를 직접 수정한 경우에는 PostgreSQL projection이나 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와 local business metadata를 조합한 응답

Redis cache miss 발생 시:

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

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

## Front 전송과 cursor 보장

front로 전달되는 사용자 목록은 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` 조직도 | `fetchUsers(5000, 0)` 단일 호출 | cursor 기반 전체 수집 helper로 전환 |
| `orgfront` 조직 picker | `fetchUsers(5000, 0)` 단일 호출 | cursor 기반 전체 수집 helper로 전환 |
| `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으로 간주하지 않습니다.
- PostgreSQL `users`를 Kratos identity total의 원장으로 사용하지 않습니다.
- Redis mirror refresh 실패를 숨기고 `ready`로 표시하지 않습니다.
- 외부 도구가 Kratos Admin API를 직접 수정하도록 허용하지 않습니다.

## 전환 작업

1. `user_projection` 명칭과 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. 기존 DB projection 기반 count를 사용하는 화면과 WORKS 비교 경로를 점검합니다.
