1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -0,0 +1,245 @@
# 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 비교 경로를 점검합니다.

View File

@@ -0,0 +1,80 @@
# Staging 502 원인 분석 및 재발방지 조치
## 개요
- 대상: `https://sso.hmac.kr`
- 관련 Actions run: `https://gitea.hmac.kr/baron/baron-sso/actions/runs/962`
- 관련 이슈: `https://gitea.hmac.kr/baron/baron-sso/issues/1025`
- 확인일: 2026-06-08
Gitea Actions run 962는 성공으로 종료됐지만, `https://sso.hmac.kr``502 Bad Gateway`를 반환했습니다. 장애는 애플리케이션 코드 레벨의 단일 오류가 아니라 스테이징 Docker Compose의 restart policy 누락으로 인해 호스트 또는 Docker 데몬 재시작 후 장기 실행 컨테이너가 자동 복구되지 않은 문제였습니다.
## 근본 원인
`docker/staging_pull_compose.template.yaml`에서 일부 장기 실행 서비스에 `restart: unless-stopped` 또는 `restart: always`가 없었습니다.
호스트 상태 확인 결과, 재시작 정책이 있던 `baron_gateway`, Postgres, Redis, ClickHouse, Oathkeeper는 자동 복구됐지만, 정책이 없던 다음 컨테이너는 종료 상태로 남았습니다.
- `baron_backend`
- `baron_userfront`
- `baron_adminfront`
- `baron_devfront`
- `baron_orgfront`
- `ory_kratos`
- `ory_hydra`
- `ory_keto`
- `ory_postgres`
- `ory_clickhouse`
결과적으로 외부 reverse proxy와 `baron_gateway`는 살아 있었지만, gateway의 upstream인 UserFront/Backend/AdminFront/DevFront/OrgFront가 없어 외부 도메인이 502를 반환했습니다. `/auth`, `/oidc` 계열 요청은 Oathkeeper가 살아 있어 JSON 404까지 도달했습니다.
## 조치
- `docker/staging_pull_compose.template.yaml`의 장기 실행 서비스에 `restart: unless-stopped`를 추가했습니다.
- 마이그레이션/유틸 컨테이너에는 restart policy를 추가하지 않았습니다.
- `kratos-migrate`
- `hydra-migrate`
- `keto-migrate`
- `ory_stack_check`
- `init-rp`
- `oathkeeper_logs_init`
- `infra_check`
- `.gitea/workflows/staging_code_pull.yml`의 배포 후 검증 범위를 확장했습니다.
- `baron_backend` health 확인
- `baron_userfront` 확인
- `baron_gateway` 확인
- 이 검증은 배포 직후 smoke test이며, 배포 이후 장애를 감지하는 운영 알람 대책은 아닙니다.
## 재발방지 테스트
추가된 정책 테스트:
```sh
sh test/staging_pull_restart_policy_test.sh
```
기존 배포 정책 테스트 강화:
```sh
sh test/staging_frontend_deploy_policy_test.sh
```
두 테스트는 다음을 보장합니다.
- 스테이징 장기 실행 서비스에는 restart policy가 있어야 합니다.
- 마이그레이션/유틸 컨테이너는 restart 대상에 포함하지 않습니다.
- 배포 성공 판정은 내부 프론트엔드뿐 아니라 backend, userfront, gateway까지 확인해야 합니다.
## 남은 과제
배포 이후 발생하는 장애를 감지하고 알림을 보내려면 별도 상시 모니터링 구성이 필요합니다. 배포 워크플로 내부 health check는 post-deploy smoke test일 뿐이므로, 머신 재시작 이후 장애 감지나 알림의 대체 수단으로 사용하지 않습니다.
## 검증 결과
복구 후 다음 외부 URL이 정상 응답을 반환했습니다.
- `https://sso.hmac.kr/` -> 200
- `https://sso.hmac.kr/ko/signin` -> 200
- `https://sadmin.hmac.kr/` -> 200
- `https://sdev.hmac.kr/` -> 200
- `https://sorg.hmac.kr/` -> 200

View File

@@ -0,0 +1,123 @@
# 사용자 projection 가시성 감사 보고서
작성 시각: 2026-06-08 16:55 KST
관련 이슈:
- #1035: adminfront 사용자 레지스트리 total이 Kratos 250건 제한으로 잘못 표시됨
- #1036: 사용자 projection 가시성 영향 범위 검증 및 WORKS 비교 표 row count 표시
## 결론
`총 250명` 표시는 단순 UI 문제가 아니라, Kratos partial list를 full snapshot처럼 처리한 projection 동기화 버그였습니다.
현재 로컬 DB와 API는 다음 상태입니다.
| 항목 | 건수 | 설명 |
| --- | ---: | --- |
| users 전체 | 2,114 | `deleted_at` 포함 전체 row |
| visible users | 1,917 | `deleted_at IS NULL`, adminfront/orgfront 사용자 목록 기준 |
| soft-deleted users | 197 | 사용자 삭제 또는 과거 projection 문제로 숨겨진 row |
| CSV 원본 줄 수 | 1,887 | 헤더 포함 |
| CSV 실제 데이터 행 | 1,886 | 헤더 제외 |
| 이번 import 사용자 | 1,886 | 모두 DB 매칭, 모두 visible |
| import ID에 없는 기존 사용자 | 228 | visible 31, soft-deleted 197 |
따라서 “기존 221명 + 신규 1887명” 계산은 그대로 적용하면 안 됩니다.
- `1887`은 CSV 헤더 포함 줄 수이고 실제 신규 데이터 행은 `1886`입니다.
- 현재 DB에서 import ID에 없는 기존 사용자는 `228`명입니다.
- 그중 `197`명은 soft-delete 상태라 adminfront/orgfront visible total에는 포함되지 않습니다.
- 현재 화면 기준 총 사용자는 `신규 visible 1886 + 기존 visible 31 = 1917`입니다.
## 생성한 대조 파일
보고 파일 위치:
- `reports/user-projection-visibility-audit-20260608-1645/existing_users_not_in_saman_import.csv`
- `reports/user-projection-visibility-audit-20260608-1645/imported_users_missing_or_soft_deleted.csv`
파일 내용:
- `existing_users_not_in_saman_import.csv`: 이번 CSV import ID에 없는 기존 사용자 228명 전체 목록입니다.
- `imported_users_missing_or_soft_deleted.csv`: 이번 import 사용자 중 DB 누락 또는 soft-delete 상태인 사용자 목록입니다. 현재는 헤더만 있고 데이터 row는 0건입니다.
## 삭제 정책 확인
adminfront 사용자 삭제는 다음 순서로 동작합니다.
1. Kratos identity 삭제를 시도합니다.
2. WORKS 연동 범위 사용자이면 WORKS delete outbox를 enqueue합니다.
3. 로컬 `users` row는 GORM `Delete`로 soft-delete 합니다.
따라서 adminfront에서 사용자 삭제를 했다고 로컬 DB row가 hard-delete 되는 구조는 아닙니다. `users.deleted_at`에 값이 들어가고, 일반 조회에서는 제외됩니다.
예외적으로 사용자 생성/재생성 시 email unique 충돌을 풀기 위한 `Unscoped` hard-delete 경로가 일부 존재합니다. 이 경로는 일반 사용자 삭제 정책과 다릅니다.
## 영향 범위 검증
### adminfront 사용자 레지스트리
- `GET /api/v1/admin/users?limit=50&offset=0`
- 응답 total: `1917`
- adminfront `/users` 화면 문구: `총 1917명의 사용자가 등록되어 있습니다.`
### orgfront 사용자 소비 경로
현재 orgfront 조직도와 picker는 `fetchUsers(5000, 0)` 형태로 사용자 목록을 한 번 가져옵니다.
검증 결과:
- `GET /api/v1/admin/users?limit=5000&offset=0`
- items: `1917`
- total: `1917`
- soft-deleted 기존 사용자 197명 중 응답 포함: `0`
현재 visible 사용자가 5,000명 미만이라 orgfront의 현 방식은 전체 visible 사용자를 모두 받습니다. 다만 사용자가 5,000명을 넘으면 partial 조회가 될 수 있으므로 cursor 기반 전환이 필요합니다.
### WORKS comparison API와 화면
WORKS comparison은 Baron visible 사용자뿐 아니라 WORKS에만 존재하는 remote row도 함께 보여주는 비교 화면입니다. 따라서 사용자 목록 visible total과 1:1로 일치하지 않습니다.
`includeMatched=true` 기준 최신 API 결과:
| 구분 | 전체 | matched | missing_in_worksmobile | missing_external_key | missing_in_baron |
| --- | ---: | ---: | ---: | ---: | ---: |
| users | 2,110 | 1,874 | 41 | 6 | 189 |
| groups | 187 | 185 | 1 | - | 1 |
WORKS 화면 row count 표시:
- 구성원: `표시 232 / 전체 2110`
- 조직/그룹: `표시 2 / 전체 187`
구성원 `표시 232`는 기본 필터와 보호 계정 제거가 적용된 화면 노출 row 수입니다. `전체 2110`은 API comparison row 전체입니다.
soft-deleted 기존 사용자 197명과 WORKS comparison `baronId`를 대조한 결과:
- soft-deleted Baron row 포함: `0`
## 왜 업데이트가 많았는가
이번 문제는 단일 UI 카운트 문제가 아니라 다음 경계가 한꺼번에 얽힌 문제였습니다.
1. Kratos identity list는 partial list인데 backend projection sync가 full snapshot으로 처리했습니다.
2. projection sync가 local DB soft-delete까지 수행해 사용자 가시성 자체를 손상시켰습니다.
3. adminfront 사용자 목록, orgfront 조직도, WORKS comparison이 모두 사용자 projection을 다른 방식으로 소비하고 있었습니다.
4. WORKS comparison은 사용자 목록이 아니라 Baron/WORKS 양쪽 차이를 보여주는 비교 화면이라 total 의미가 달랐습니다.
5. 운영자가 partial data인지 바로 볼 수 있도록 WORKS 표 row count가 필요했습니다.
## pagination 정리
현재 구조는 다음과 같습니다.
- Ory/Kratos -> backend projection sync: 현재 `ListIdentities()` partial 조회입니다. offset/cursor 전체 순회가 아닙니다.
- backend projection -> adminfront 사용자 목록: `cursor`가 있으면 cursor pagination, 없으면 offset pagination을 받습니다. adminfront는 infinite query로 `nextCursor`를 사용합니다.
- backend projection -> orgfront 조직도/picker: 현재 `limit=5000&offset=0` 단일 offset 조회입니다.
- WORKS comparison: backend가 비교 결과 배열을 만들어 내려주고, adminfront가 검색/필터 후 화면 row를 표시합니다.
## 재발 방지 조치
- 사용자 목록 API는 Kratos가 아니라 local projection DB를 primary source로 사용합니다.
- Kratos partial list에 없는 사용자를 projection sync에서 삭제하지 않도록 수정했습니다.
- WORKS comparison에서 soft-deleted local user가 들어와도 comparison row로 노출되지 않도록 방어 테스트와 로직을 추가했습니다.
- WORKS comparison 표에 `표시 N / 전체 M` row count를 표시했습니다.

View File

@@ -0,0 +1,112 @@
# WORKS only 사용자 복구 보고서
관련 이슈: #1037
## 요약
- 작업 시각: 2026-06-09 KST
- 대상: WORKS 비교 결과에서 `missing_in_baron`으로 보이던 사용자 row
- 최신 후보 수: 189명
- 조치: Baron `users.deleted_at` soft-delete 해제
- Kratos 조치: 직접 삽입/수정 없음. 189개 Kratos identity가 모두 현재 DB에 active 상태로 존재함을 확인함
- 최종 결과: WORKS 사용자 비교의 `missing_in_baron` 0건
## 원인
이전 사용자 projection 동기화 코드가 Kratos `ListIdentities()` 결과를 전체 identity 목록으로 간주했습니다. 해당 API 결과는 제한된 페이지 결과였고, 그 목록에 없던 기존 사용자가 Baron `users`에서 soft-delete 처리되었습니다.
이로 인해 WORKS에는 사용자가 남아 있고 `externalKey`도 Baron 사용자 UUID를 가리키지만, Baron 비교 로직에서는 soft-deleted 사용자가 visible 사용자로 조회되지 않아 `missing_in_baron`으로 표시되었습니다.
## 대조 결과
복구 전 후보 189건 기준:
| 구분 | 현재 Baron | 과거 Baron 백업 | 현재 Kratos | 과거 Kratos 백업 |
| --- | ---: | ---: | ---: | ---: |
| 후보 | 189 | 189 | 189 | 189 |
| visible 사용자 | 0 | 189 | - | - |
| soft-delete 사용자 | 189 | 0 | - | - |
| 누락 사용자 | 0 | 0 | 0 | 0 |
| active identity | - | - | 189 | 189 |
| credential 보유 identity | - | - | 189 | 189 |
확인한 백업:
- `backups/pre-saman-works-users-20260608-063605Z`
- `backups/pre-works-only-user-recovery-20260609-083105KST`
상세 리포트:
- `reports/works-only-user-recovery-20260609-0832/missing_in_baron_external_keys.csv`
- `reports/works-only-user-recovery-20260609-0832/current_baron_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/pre_saman_baron_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/current_kratos_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/pre_saman_kratos_candidate_status.csv`
- `reports/works-only-user-recovery-20260609-0832/post_recovery_baron_candidate_status.csv`
## 조치 내용
복구 전 DB 백업을 생성했습니다.
- `make dump DUMP_SERVICES=postgres,ory-postgres BACKUP=backups/pre-works-only-user-recovery-20260609-083105KST`
이후 후보 189건에 대해 현재 Baron DB에서 다음 조건으로만 soft-delete를 해제했습니다.
- WORKS `missing_in_baron` row의 `externalKey`가 UUID여야 함
- 해당 UUID가 현재 Baron `users.id`에 존재해야 함
- 해당 Baron row가 `deleted_at IS NOT NULL`이어야 함
- 해당 UUID가 현재 Kratos `identities.id`에 active 상태로 존재해야 함
복구 결과:
- 복구된 Baron 사용자: 189명
- 전체 Baron `users`: 2114명
- visible 사용자: 2106명
- soft-delete 사용자: 8명
- 후보 189명 중 Kratos active identity: 189명
- 후보 189명 중 credential 보유 identity: 189명
## 최종 검증
실제 Kratos admin 세션으로 로컬 게이트웨이를 경유해 검증했습니다.
- `GET /api/v1/admin/users?limit=5000&offset=0`
- HTTP 200
- `total=2106`
- `items=2106`
- `GET /api/v1/admin/tenants/038326b6-954a-48a7-a85f-efd83f62b82a/worksmobile/comparison?includeMatched=true`
- HTTP 200
- 사용자 비교 총 2110건
- `matched=2073`
- `missing_in_baron=0`
- `missing_in_worksmobile=30`
- `needs_update=1`
- `missing_external_key=6`
- 조직/그룹 비교 총 187건
- 조직/그룹 `missing_in_baron=1`
테스트:
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/service -run 'TestWorksmobileSyncServiceSkipsSoftDeletedUsersInComparison' -count=1`
- `GOCACHE=/tmp/baron-sso-go-cache go test ./internal/handler ./internal/repository -run 'Test.*User|Test.*Projection|Test.*SoftDeleted|Test.*ListUsers' -count=1`
- `BASE_URL=http://127.0.0.1:5173 npm --prefix adminfront test -- worksmobile.spec.ts --project=chromium`
결과:
- Go service 테스트 통과
- Go handler/repository 테스트 통과
- adminfront Worksmobile Playwright 4개 테스트 통과
## 재발 방지
이미 적용된 코드 변경으로 다음 조건을 방어합니다.
- admin 사용자 목록은 Kratos 250개 제한 결과가 아니라 로컬 projection repository를 기준으로 조회합니다.
- projection replace 동기화는 Kratos partial list에 없는 사용자를 삭제 처리하지 않습니다.
- WORKS 비교 로직은 soft-deleted Baron 사용자를 visible 사용자로 취급하지 않습니다.
- WORKS 비교 UI에는 필터링 후 표시 row와 전체 row 수를 함께 표시합니다.
남은 확인 항목:
- 조직/그룹 비교의 `missing_in_baron=1`은 사용자 복구와 별개 항목입니다. 별도 이슈로 원인 확인이 필요합니다.
- `missing_external_key=6` 사용자 row는 WORKS 측 externalKey가 없으므로 자동 복구 대상에서 제외했습니다.

View File

@@ -0,0 +1,64 @@
# Worksmobile API Rate Limit 정책
작성일: 2026-06-09
## 목적
Worksmobile relay worker는 외부 API 제한을 초과하지 않도록 Works API 호출에 rate limit을 적용한다.
일반 조회, 수동 enqueue, 비교 화면처럼 backend 내부 처리량이 병목이 아닌 경로는 기본 제약하지 않는다.
## 기준
- 제한 단위: API별
- 제한값: 240 requests/min
- 환산 간격: 같은 API key 기준 요청 시작 간격 250ms 이상
- API key: HTTP method + normalized path
- query string은 rate limit key에서 제외한다.
- 리소스 ID가 포함된 path segment는 `{id}`로 정규화한다.
예:
- `POST /v1.0/users`
- `PATCH /v1.0/users/{id}`
- `POST /v1.0/users/{id}/alias-emails/{id}`
- `GET /v1.0/orgunits`
- `PATCH /scim/v2/Users/{id}`
- `POST /oauth2/v2.0/token`
## 적용 범위
Backend `WorksmobileHTTPClient`는 optional limiter 주입 지점을 제공한다.
기본 client 생성자는 limiter를 갖지 않고, `WorksmobileRelayWorker`에 전달되는 client copy에만 limiter를 적용한다.
또한 Worksmobile relay worker에만 Redis leader lock을 적용해 여러 backend replica 중 하나만 outbox relay를 수행하게 한다.
적용 대상:
- Worksmobile relay worker가 수행하는 Directory API 조회/쓰기
- Worksmobile relay worker가 수행하는 SCIM API 조회/쓰기
- Worksmobile relay worker가 수행하는 OAuth token 요청
기본 제외 대상:
- admin 화면의 조회/비교 API
- 수동 sync 요청의 enqueue 단계
- Worksmobile outbox에 job을 적재하는 내부 처리
## 구현 정책
- production 기본 client는 limiter를 갖지 않는다.
- relay worker에는 limiter를 가진 client copy를 전달한다.
- relay worker는 Redis leader lock을 보유한 replica에서만 `worksmobile_outbox` ready job을 조회한다.
- leader lock은 Worksmobile relay 전용이며, 다른 relay worker나 일반 backend 작업에는 적용하지 않는다.
- leader lock 설정은 환경변수로 열지 않고 코드 상수로 고정한다.
- key: `baron:worksmobile:relay:leader`
- ttl: 30s
- 기본 limiter는 API key별 요청 시작 시각을 직렬화한다.
- context가 취소되면 대기 중인 요청은 `ctx.Err()`로 실패한다.
- 테스트나 특수 호출자는 `WorksmobileRateLimiter`를 주입해 limiter 동작을 검증하거나 대체할 수 있다.
## 운영 메모
- 현재 worker 정책은 burst를 허용하지 않는 보수적 제한이다.
- 외부 API가 `Retry-After`를 제공하는 경우, 별도 retry/backoff 정책을 추가할 수 있다.
- 여러 backend replica에서 relay worker가 동시에 시작되더라도 Redis leader lock으로 하나의 replica만 relay를 수행한다.
- Redis 연결이 초기화되지 않은 환경에서는 leader lock을 주입하지 않으며, 단일 인스턴스/dev 실행처럼 기존 방식으로 동작한다.