forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
245
docs/identity-redis-mirror-policy-2026-06-09.md
Normal file
245
docs/identity-redis-mirror-policy-2026-06-09.md
Normal 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 비교 경로를 점검합니다.
|
||||
80
docs/staging-502-restart-policy-incident-2026-06-08.md
Normal file
80
docs/staging-502-restart-policy-incident-2026-06-08.md
Normal 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
|
||||
123
docs/user-projection-visibility-audit-2026-06-08.md
Normal file
123
docs/user-projection-visibility-audit-2026-06-08.md
Normal 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를 표시했습니다.
|
||||
112
docs/works-only-user-recovery-2026-06-09.md
Normal file
112
docs/works-only-user-recovery-2026-06-09.md
Normal 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가 없으므로 자동 복구 대상에서 제외했습니다.
|
||||
64
docs/worksmobile-api-rate-limit-policy-2026-06-09.md
Normal file
64
docs/worksmobile-api-rate-limit-policy-2026-06-09.md
Normal 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 실행처럼 기존 방식으로 동작한다.
|
||||
Reference in New Issue
Block a user