From 1274c56cb58a48e65d24675b3e4654ada594c3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 20 May 2026 01:04:26 +0800 Subject: [PATCH] add member design --- docs/identity-member-design.md | 1895 +++++++++++++++++++++++++++----- 1 file changed, 1630 insertions(+), 265 deletions(-) diff --git a/docs/identity-member-design.md b/docs/identity-member-design.md index e9e8024..b5fafdc 100644 --- a/docs/identity-member-design.md +++ b/docs/identity-member-design.md @@ -24,14 +24,16 @@ 8. [Middleware 鏈](#8-middleware-鏈) 9. [核心流程](#9-核心流程) 10. [LDAP 與 SCIM](#10-ldap-與-scim) -11. [可讀 UID 設計](#11-可讀-uid-設計) -12. [資料模型與索引](#12-資料模型與索引) -13. [Redis Key 命名](#13-redis-key-命名) -14. [規模與性能(100 萬+ / 單租戶 50 萬)](#14-規模與性能100-萬--單租戶-50-萬) -15. [目錄結構](#15-目錄結構) -16. [設定檔](#16-設定檔) -17. [實施順序](#17-實施順序) -18. [待決策事項](#18-待決策事項) +11. [Notification Module](#11-notification-module) +12. [可讀 UID 設計](#12-可讀-uid-設計已決策) +13. [資料模型與索引](#13-資料模型與索引) +14. [Redis Key 命名](#14-redis-key-命名) +15. [規模與性能(100 萬+ / 單租戶 50 萬)](#15-規模與性能100-萬--單租戶-50-萬) +16. [目錄結構](#16-目錄結構) +17. [設定檔](#17-設定檔) +18. [實施順序](#18-實施順序) +19. [已決策事項](#19-已決策事項) +20. [Audit Log 與 Rate Limit](#20-audit-log-與-rate-limit) --- @@ -47,7 +49,7 @@ | Token | go-zero JWT 驗證 + Redis 黑名單(只黑名單 JWT) | | 企業整合 | SCIM 2.0 + LDAP Directory Sync(AD + OpenLDAP) | | 規模 | 全平台 100 萬+ 會員;單租戶可達 50 萬 | -| UID | 人類可讀、可口述,非 UUID / 亂碼 | +| UID | 人類可讀、帶租戶前綴,如 `AMEX-10000000`;唯一性以 `tenant_id + uid` 為準 | ### 1.2 核心原則 @@ -71,8 +73,15 @@ 5. **B2B 權限自定義**(參考 [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)) - 平台 seed 全局 Permission Tree(含 `http_path` / `http_method`) - 租戶建立自訂 Role,從 Tree **勾選** Permission(`RolePermission` + 自動補 parent) - - API 授權由 **Casbin** 比對 `(role.Name, path, method)` - - B2C 租戶仅用 seed 模板 + - API 授權由 **Casbin** 比對 `(tenant_id, role_key, path, method)`,避免不同租戶同名角色互相污染 + - B2C 租戶**唯讀** seed 模板,**不可**自定義 Role(已決策) + +6. **身份驗證 vs 業務驗證分層**(已決策) + - **ZITADEL = 身份級驗證**:登入 MFA(TOTP / WebAuthn / SMS)、註冊 email 驗證、忘記密碼、帳號鎖定 + - **Gateway member = 業務級驗證**:業務 email / phone 綁定 OTP、Step-up MFA + - Gateway **不**依賴 `ZITADEL email_verified` 當業務守門條件;Logic 層改讀 `BusinessEmailVerified` 等 member 旗標 + - **Email / SMS OTP 由 Gateway 自送**(不轉 ZITADEL Notification) + - **MFA 強制策略**:admin 級 role 由 ZITADEL Org Policy 強制 TOTP;一般 user 預設不強制,但高風險操作走 Gateway Step-up --- @@ -89,55 +98,68 @@ │ jwt_revoke · casbin_rbac · scim_auth · tenant_context │ ├─────────────────────────────────────────────────────────────────┤ │ internal/model/ │ -│ auth/ → Token 簽發、換票、登出、黑名單、auth_gen │ -│ member/ → Profile、Identity、Tenant、UID、Directory Sync │ -│ permission/ → Casbin RBAC、Permission Tree、Role(B2B 自定義)│ +│ auth/ → Token 簽發、換票、登出、黑名單、auth_gen、step-up│ +│ member/ → Profile、Identity、Tenant、UID、Sync、TOTP、驗證│ +│ permission/ → Casbin RBAC、Permission Tree、Role(B2B 自定義)│ +│ notification/ → Email/SMS/Push 統一發送、模板、重試、audit │ ├─────────────────────────────────────────────────────────────────┤ │ internal/library/ │ -│ zitadel/ · ldap/ · uid/ · casbin/(RBAC enforcer 封裝) │ +│ zitadel/ · ldap/ · uid/ · casbin/ │ +│ notification/email · notification/sms · notification/push │ ├─────────────────────────────────────────────────────────────────┤ │ internal/worker/ │ -│ directory_sync/ │ +│ directory_sync/ · notification_retry/ · member_anonymize/ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ MongoDB Redis ZITADEL (profile/role) (cache/blacklist) (identity/LDAP IdP) + + + Email / SMS Provider ``` ### 2.1 模組依賴方向 ``` -handler → logic → model/{auth|member|permission}/usecase(interface) +handler → logic → model/{auth|member|permission|notification}/usecase(interface) ↓ repository → MongoDB / Redis logic 不 import entity / repository(見 model.md) -auth → member(EnsureMember) -auth → permission(SyncRolesFromClaims) -member → auth(停權時 RevokeAllForUser) -permission → member(可選:驗證 uid 存在) +auth → member(EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM) +auth → permission(SyncRolesFromClaims) +auth → member.TOTPUseCase(step-up TOTP 驗證) +member → auth(停權時 RevokeAllForUser) +member → notification(業務驗證 / step-up OTP 寄送) +permission → member(可選:驗證 uid 存在) +notification → library/notification/{email,sms,push}(provider 實作) ``` --- ## 3. 外部系統分工 -| 能力 | ZITADEL | Gateway auth | Gateway member | Gateway permission | -|------|---------|--------------|----------------|-------------------| -| 註冊 / 登入 | ✅ | 換票 | EnsureMember | SyncRoles | -| 密碼 / MFA / 忘記密碼 | ✅ | — | — | — | -| Google / LINE / Apple | ✅ IdP | — | — | — | -| LDAP 登入 | ✅ LDAP IdP | — | — | Group→Role 映射 | -| Access / Refresh Token(對外) | — | ✅ CloudEP JWT | — | — | -| JWT 黑名單 | — | ✅ Redis | — | — | -| 業務 UID | — | — | ✅ | — | -| Profile | — | — | ✅ | — | -| 會員列表 / 狀態 | — | — | ✅ | 需授權 | -| API 細粒度權限 | 粗粒度 Role | — | — | **Casbin RBAC**(path + method) | -| SCIM Users/Groups | 可同步 | — | ✅ 業務寫入 | ✅ Group→Role | -| LDAP Directory Sync | — | — | ✅ Worker | ✅ Group→Role | +| 能力 | ZITADEL | Gateway auth | Gateway member | Gateway permission | Gateway notification | +|------|---------|--------------|----------------|-------------------|----------------------| +| 註冊 / 登入(OIDC / LDAP / SCIM) | ✅ | 換票 | EnsureFromOIDC/LDAP/SCIM | SyncRoles | — | +| 平台原生註冊(未來,含 email OTP) | (local user)| — | LifecycleUseCase + OTPUseCase | — | 寄 OTP | +| 密碼 / 身份 MFA / 忘記密碼 | ✅ | — | — | — | — | +| 身份 MFA 強制策略 | ✅ Org Policy | — | — | — | — | +| Google / LINE / Apple | ✅ IdP | — | — | — | — | +| LDAP 登入 | ✅ LDAP IdP | — | — | Group→Role 映射 | — | +| Access / Refresh Token(對外) | — | ✅ CloudEP JWT | — | — | — | +| Step-up Token(高風險操作) | — | ✅ 簽 step_up_token | OTP / TOTP 驗證 | Logic 守門 | OTP 寄送 | +| 業務 TOTP(Authenticator) | — | — | ✅ secret 加密儲存 + 驗證 | — | — | +| JWT 黑名單 | — | ✅ Redis | — | — | — | +| 業務 UID | — | — | ✅ | — | — | +| Profile | — | — | ✅ | — | — | +| 業務 Email / Phone 驗證 | — | — | ✅ Verification 流程 | — | ✅ OTP 寄送 | +| Email / SMS / Push 發送 | — | — | — | — | ✅ 統一入口 + 模板 + 重試 | +| 會員列表 / 狀態 | — | — | ✅ | 需授權 | 變更通知(異步) | +| API 細粒度權限 | 粗粒度 Role | — | — | **Casbin RBAC**(path + method) | — | +| SCIM Users/Groups | 可同步 | — | ✅ 業務寫入 | ✅ Group→Role | — | +| LDAP Directory Sync | — | — | ✅ Worker | ✅ Group→Role | 同步異常告警 | ### 3.1 多租戶對應 @@ -149,7 +171,24 @@ permission → member(可選:驗證 uid 存在) |------|------|------| | `tenant_id` | ZITADEL `org_id` | 分片鍵、授權邊界 | | `identity_id` | ZITADEL `sub` | 身份映射 | -| `uid` | Member 模組產生 | 業務會員 ID(如 `ACME-ODWXGYBK`) | +| `uid` | Member 模組產生 | 業務會員 ID(如 `AMEX-10000000`) | + +#### Tenant 建立順序(已決策:Gateway 先建草稿) + +``` +1. POST /api/v1/admin/tenants { slug, uid_prefix, type, ... } + → Mongo upsert tenants {status: "provisioning", org_id: ""} +2. ZITADEL Mgmt.CreateOrganization(name=slug) + → 拿到 org_id +3. UPDATE tenants {org_id, status: "active"} +4. seed 預設 Role + Casbin policy reload +5. 回傳 tenant payload +失敗補償: +- 步驟 2 失敗 → status = "failed",cron 重試(指數退避,3 次後人工介入) +- 步驟 3 失敗 → status = "orphan_zitadel_org",cron 偵測並補綁 +``` + +> Saga 風格:Gateway 為主、ZITADEL 為從;補償 cron 每 5 分鐘掃 `status in ("failed", "orphan_zitadel_org")` 重試或告警。 ### 3.2 租戶類型 @@ -157,7 +196,32 @@ permission → member(可選:驗證 uid 存在) |------|------|------|------| | **B2C** | Email / Social | 無 | 系統預設 Role(不可或不常自定義) | | **B2B** | ZITADEL → LDAP IdP | 有 | **完全自定義 Role + Permission** | -| **Hybrid** | Social + LDAP | 有 | B2B 自定義 + 外部客戶用預設 Role | +| **Hybrid** | Social + LDAP | 有 | B2B 自定義;外部客戶用 B2C 唯讀模板 | + +### 3.3 ZITADEL 部署(已決策:Self-hosted) + +- **部署方式**:Self-hosted(自建),與 Gateway / Mongo / Redis 同環境或同 VPC +- **LDAP 網路**:ZITADEL 實例需能直連企業 AD / OpenLDAP(常見:VPN、專線、或 DMZ 轉發) +- **Management API / JWKS**:Gateway 透過內網 URL 存取,不經公網 +- **設定**:`etc/gateway.yaml` 的 `Zitadel.Issuer` / `MgmtURL` 指向 self-hosted 端點 + +### 3.4 註冊路徑(已決策:不提供 Gateway 註冊 API) + +Gateway **不暴露** `/auth/register`。註冊由下列路徑完成: + +| 租戶類型 | 註冊路徑 | 首次登入副作用 | +|---------|----------|----------------| +| **B2C** | ZITADEL Hosted Register UI(或前端走 ZITADEL OIDC PKCE) | token exchange 觸發 `EnsureFromOIDC` JIT | +| **B2B(LDAP)** | 由 IT 在 AD / OpenLDAP 建帳;可選 Directory Sync 預 provision 到 ZITADEL | LDAP IdP 登入觸發 `EnsureFromLDAP` JIT | +| **B2B(SCIM)** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway(不需 JIT) | + +> ZITADEL 內建 email 驗證已完成「**可登入**」門檻;業務上「**可使用功能**」門檻見 §5.4 業務驗證。 + +### 3.5 平台 MFA 強制(已決策) + +- ZITADEL Org Policy 設定:**任何 admin 級 role**(`tenant_owner` / `tenant_admin` / `platform_super_admin`)登入時強制 TOTP / WebAuthn +- 一般 user 預設不強制(避免 B2C 流失) +- 高風險業務操作 → 走 Gateway Step-up MFA(§5.6),與 ZITADEL 身份 MFA **互不取代** --- @@ -168,8 +232,9 @@ permission → member(可選:驗證 uid 存在) ### 4.1 職責 - 驗證 ZITADEL OIDC token(id_token / authorization_code + PKCE) -- 編排 `member.EnsureMember` 與 `permission.SyncRolesFromClaims` +- 編排 `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` 與 `permission.SyncRolesFromClaims` - 簽發 CloudEP JWT(access + refresh) +- **簽發 Step-up Token**(高風險操作用,短壽命 5min;見 §5.6) - 登出:jti 黑名單 - 批量失效:`auth_gen`(停權 / 改密碼 / 權限強制刷新) @@ -182,6 +247,12 @@ type TokenUseCase interface { Logout(ctx context.Context, req *LogoutRequest) error RevokeAllForUser(ctx context.Context, tenantID, uid string) error } + +type StepUpTokenUseCase interface { + Issue(ctx context.Context, tenantID, uid, action string) (stepUpToken string, err error) + Verify(ctx context.Context, token, expectedAction, tenantID, uid string) (jti string, err error) + MarkUsed(ctx context.Context, jti string) error // 單次性 +} ``` ### 4.3 CloudEP JWT Claims @@ -191,32 +262,72 @@ type Claims struct { jwt.RegisteredClaims // 含 jti, exp, iat TenantID string `json:"tenant_id"` UID string `json:"uid"` - Roles string `json:"roles"` // 逗號分隔 Role.Name,Casbin enforce 用 - Typ string `json:"typ"` // access | refresh - AuthGen int64 `json:"auth_gen"` // 批量失效代號 + Typ string `json:"typ"` // access | refresh | step_up + AuthGen int64 `json:"auth_gen"` // 批量失效代號(簽發時 = redis.GET 當前值;不存在視為 0) + Action string `json:"action,omitempty"` // typ=step_up 時必填,鎖定允許執行的高風險 action } ``` -### 4.4 JWT 設定(go-zero) +> **JWT 內不放 role / permission 快照**。Middleware 每次從 `perm:user_roles:{tenant_id}:{uid}` cache 讀取當前 role keys 再 enforce;避免「改名 / 撤角 / 變更權限」後舊 token 還能用。 +> 角色變更立即生效靠 `auth_gen` + cache invalidate;不依賴 token 內容。 + +### 4.4 JWT 設定(go-zero)+ Secret Rotation(已決策) ```yaml Auth: - AccessSecret: ${JWT_ACCESS_SECRET} AccessExpire: 900 # 15 分鐘 + ActiveKID: v2 # 當前簽發用 kid + Keys: # 驗證可接受的 kid 名單(含正在退役的) + - kid: v1 + Secret: ${JWT_ACCESS_SECRET_V1} + - kid: v2 + Secret: ${JWT_ACCESS_SECRET_V2} RefreshAuth: - AccessSecret: ${JWT_REFRESH_SECRET} AccessExpire: 604800 # 7 天 + ActiveKID: v2 + Keys: + - kid: v1 + Secret: ${JWT_REFRESH_SECRET_V1} + - kid: v2 + Secret: ${JWT_REFRESH_SECRET_V2} + +StepUp: + TokenTTLSeconds: 300 + ActiveKID: v1 + Keys: + - kid: v1 + Secret: ${JWT_STEPUP_SECRET_V1} ``` +**Rotation 流程:** + +``` +1. 新增 v(N+1) key 到 Keys(不改 ActiveKID)→ rolling deploy +2. 切 ActiveKID = v(N+1) → 新 token 用新 kid 簽;舊 kid token 仍可驗 +3. 等舊 token 全部過期(access 15min / refresh 7d) +4. 從 Keys 移除舊 kid → rolling deploy +``` + +- JWT header 必帶 `kid`,驗證時依 `kid` 找 secret;找不到 → `401 invalid_kid` +- go-zero 內建 JWT middleware 僅吃單 secret,**自寫 `JwtMultiKeyMiddleware`** 取代或前置(在 `JwtRevokeMiddleware` 之前) +- ZITADEL Token Exchange、Step-up 共用此架構 + `.api` 受保護路由: ```api -@server(jwt: Auth, middleware: JwtRevokeMiddleware) +@server(jwt: Auth, middleware: JwtMultiKeyMiddleware,JwtRevokeMiddleware) ``` ### 4.5 黑名單策略(只黑名單 JWT) +#### Issue Token Pair 時記對應(讓 logout 不必帶 refresh) + +``` +SET auth:jwt:pair:{access_jti} = refresh_jti TTL = access TTL +SET auth:jwt:pair:{refresh_jti} = access_jti TTL = refresh TTL +``` + #### 單 Token 撤銷(登出) ``` @@ -225,9 +336,57 @@ Value: 1 TTL: token 剩餘有效時間(exp - now) ``` -登出時同時黑名單 access + refresh 的 jti。 +``` +POST /auth/logout (Bearer access_jwt) + 1. 解 access_jti → SET auth:jwt:bl:{access_jti} + 2. GET auth:jwt:pair:{access_jti} → refresh_jti(若存在) + 3. SET auth:jwt:bl:{refresh_jti} + 4. DEL auth:jwt:pair:{access_jti} / auth:jwt:pair:{refresh_jti} +``` -#### 批量失效(停權 / 改密碼 / SCIM deactivate / 角色強刷) +#### Refresh Token 輪換(已決策)+ Reuse Detection + +``` +POST /auth/token/refresh + 1. 驗證 refresh_jwt(typ=refresh、未過期、auth_gen 有效) + 2. 若 refresh_jti 已在黑名單: + 視為被竊或重放 → INCR auth:gen:{tenant_id}:{uid}(撤銷整條 chain) + 回 401,並寫 audit log + 3. 簽發新 access_jwt + 新 refresh_jwt(新 jti) + 4. 黑舊 refresh_jti;若舊 access 對應 jti 仍未過期,一併黑名單 + 5. 寫入新的 auth:jwt:pair +``` + +- 每次 refresh 都輪換(Refresh Token Rotation) +- **Reuse detection**:舊 refresh 被第二次使用 → 視同盜用,立即批量撤銷該 user + +#### Token Exchange 防重放 + +``` +POST /auth/token/exchange { tenant_slug, id_token } + 1. zitadel.VerifyIDToken(檢 aud、iss、exp、signature) + 2. 強制檢查 id_token.iat 在最近 5 分鐘內 + 3. SETNX auth:exchange:nonce:{id_token.jti}=1 TTL 10min;失敗 → 409 已使用 + 4. 校驗 tenant_slug → tenant.org_id == id_token.org_id + 5. EnsureFromOIDC / SyncRoles / IssueTokenPair +``` + +#### Step-up Token(單次性、鎖 action) + +``` +Key: auth:stepup:used:{jti} SETNX TTL = step_up_token TTL +Value: 1 +``` + +- TTL:5 分鐘 +- Claims:`typ=step_up` + `action`(如 `change_business_email`) +- Logic 層守門: + 1. 解 step_up JWT → 檢 `typ == "step_up"`、`tenant_id`、`uid`、`action == expected` + 2. `SETNX auth:stepup:used:{jti}=1`,已存在 → 視為重放,拒絕 + 3. 通過後執行高風險操作;token 即作廢 +- Step-up token **不**進 jti 黑名單系統;單次性靠 `auth:stepup:used` 即可 + +#### 批量失效(停權 / 改密碼 / SCIM deactivate / **權限變更**) ``` Key: auth:gen:{tenant_id}:{uid} @@ -236,16 +395,24 @@ Value: 整數,預設 1;事件發生時 INCR Middleware 檢查:`token.auth_gen >= redis.auth_gen`,否則 401。 +> **已決策**:UserRole 指派/撤銷、外部 Group 映射導致的 user role 變更 → **`INCR auth_gen`**(等效強制刷新,使用者需重新 exchange/refresh 取得新 auth_gen)。 +> +> RolePermission 變更不改變「使用者有哪些角色」,只需 `LoadPolicy(tenant_id)` + 權限快取失效;若未來改成完全信任 JWT 內角色/權限快照,才需要同步 `INCR auth_gen`。 + > JWT 內不放全部 permission(避免 token 過大);批量失效用 `auth_gen`,單次登出用 jti 黑名單。 ### 4.6 Middleware 檢查順序 ``` +0. Platform Admin allowlist 命中(platform tenant + platform_super_admin role 或 break-glass UID) + → audit.LogPlatformBypass → 直接放行 1. go-zero JWT 驗簽 + exp 2. typ == "access"(受保護 API) 3. NOT EXISTS auth:jwt:bl:{jti} 4. claims.auth_gen >= redis auth:gen:{tenant}:{uid} -5. 注入 context:tenant_id, uid, roles + - redis key 不存在 → 視為 0 + - 簽發 token 時 claims.auth_gen = redis.GET 或 0 +5. 注入 context:tenant_id, uid(role keys 由下一層 CasbinRBACMiddleware 從 cache 載入) ``` --- @@ -258,41 +425,112 @@ Middleware 檢查:`token.auth_gen >= redis.auth_gen`,否則 401。 - 會員 Profile CRUD(tenant-scoped) - Identity 映射(`zitadel_sub` ↔ `uid`) -- Tenant metadata 與 LDAP 同步設定 +- Tenant metadata 與 LDAP 同步設定 - UID 產生(可讀格式) -- SCIM 業務寫入(User + externalId = uid) +- SCIM 業務寫入(SCIM `id` / Gateway UID + 客戶端 `externalId`) - Directory Sync Worker(AD + OpenLDAP) -- 會員狀態(active / suspended)→ 通知 auth 撤銷 token +- 會員狀態(active / suspended / deleted)→ 通知 auth 撤銷 token +- **業務級驗證**:business email / phone 綁定 + OTP 自送 +- **Step-up MFA OTP 驗證**(搭配 auth 模組簽 step_up_token) ### 5.2 UseCase 介面 -```go -type ProvisioningUseCase interface { - EnsureMember(ctx context.Context, req *EnsureMemberRequest) (*MemberDTO, error) -} +> **設計原則(呼應 model.md)**:每個 UseCase 是**原子業務操作**,**不假設前後步驟存在**。流程編排(如「註冊 → 寄驗證信 → 啟用」)由 **logic 層**用多個 UseCase 拼裝;本層只負責單一動作 + 副作用。 +> +> 介面分兩層: +> 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification)。Logic 可任意組合,跨流程共用。 +> 2. **Composite**:把幾個常用 atomic 預先組好的「快捷組合」(如 `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`)。Composite 是**可選**,logic 也可以繞過直接組 atomic。 +> +> 業務邏輯(API、handler、流程編排)目前**不實作**;先固化介面契約。 +#### 5.2.1 Atomic primitives + +```go +// ────────────────────────────────────────────────────────── +// Profile:讀寫 member 欄位(不含啟用 / 停權等狀態變遷) +// ────────────────────────────────────────────────────────── type ProfileUseCase interface { GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error) List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) + + // 業務 email / phone 旗標切換(被 Verification 或外部流程使用) + SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error + SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error } -type AdminUseCase interface { - UpdateStatus(ctx context.Context, req *UpdateStatusRequest) error +// ────────────────────────────────────────────────────────── +// Lifecycle:狀態變遷的單一動作;不寄信、不簽 token +// ────────────────────────────────────────────────────────── +type LifecycleUseCase interface { + // 平台原生註冊:建立 unverified member(不寄 OTP,不發 token) + CreateUnverified(ctx context.Context, req *CreatePlatformMemberRequest) (*MemberDTO, error) + // 啟用:unverified → active;caller 須先確保所有前置驗證已通過 + Activate(ctx context.Context, tenantID, uid string) error + // 停權:active → suspended;不撤 token(撤 token 由 auth 模組做) + Suspend(ctx context.Context, tenantID, uid, reason string) error + // 復權:suspended → active + Reactivate(ctx context.Context, tenantID, uid string) error + // 軟刪:active|suspended → deleted(不會立刻匿名化;30 天後由 worker 處理 §5.7) + SoftDelete(ctx context.Context, tenantID, uid string) error + // 中止未啟用註冊(逾時清理;只能對 unverified 用) + AbortPending(ctx context.Context, tenantID, uid string) error } +// ────────────────────────────────────────────────────────── +// Provisioning:外部來源 → Gateway member 的 JIT / sync upsert +// 每個來源獨立一個動作;email 視為來源 IdP 已驗證,不再走 OTP +// ────────────────────────────────────────────────────────── +type ProvisioningUseCase interface { + // ZITADEL OIDC token exchange:用 id_token claims 上 upsert(B2C / Social IdP) + EnsureFromOIDC(ctx context.Context, req *EnsureFromOIDCRequest) (*MemberDTO, error) + // ZITADEL LDAP IdP 登入後 JIT;或 Directory Sync worker 推送 + EnsureFromLDAP(ctx context.Context, req *EnsureFromLDAPRequest) (*MemberDTO, error) + // SCIM Create / Update User + EnsureFromSCIM(ctx context.Context, req *EnsureFromSCIMRequest) (*MemberDTO, error) +} + +// ────────────────────────────────────────────────────────── +// OTP:atomic、purpose-agnostic 一次性密碼 +// 不寄信、不更新 member;caller 拿 code 後自行透過 NotifierUseCase 投遞 +// ────────────────────────────────────────────────────────── +type OTPUseCase interface { + // 生成:bcrypt 存 redis,回 challenge_id + 明碼 code(一次性回傳) + Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, error) + // 驗證:成功則 invalidate;purpose 必須與 challenge 建立時一致 + Verify(ctx context.Context, req *VerifyOTPRequest) error + // 主動失效(換 challenge / 取消註冊) + Invalidate(ctx context.Context, tenantID, challengeID string) error +} + +// ────────────────────────────────────────────────────────── +// TOTP(Authenticator App):見 §5.8 +// ────────────────────────────────────────────────────────── +type TOTPUseCase interface { + StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error) + ConfirmEnroll(ctx context.Context, tenantID, uid, code string) (backupCodes []string, err error) + VerifyCode(ctx context.Context, tenantID, uid, code string) error + Disable(ctx context.Context, tenantID, uid string) error + RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) +} + +// ────────────────────────────────────────────────────────── +// Tenant +// ────────────────────────────────────────────────────────── type TenantUseCase interface { Create(ctx context.Context, req *CreateTenantRequest) (*TenantDTO, error) ResolveBySlug(ctx context.Context, slug string) (*TenantDTO, error) ConfigureLDAP(ctx context.Context, req *ConfigureLDAPRequest) error } +// ────────────────────────────────────────────────────────── +// SCIM Resource handlers +// ────────────────────────────────────────────────────────── type ScimUseCase interface { CreateUser(ctx context.Context, req *ScimCreateUserRequest) (*ScimUserDTO, error) GetUser(ctx context.Context, req *ScimGetUserRequest) (*ScimUserDTO, error) PatchUser(ctx context.Context, req *ScimPatchUserRequest) (*ScimUserDTO, error) DeleteUser(ctx context.Context, req *ScimDeleteUserRequest) error - // Groups(供 SCIM Group → Role 映射) PatchGroup(ctx context.Context, req *ScimPatchGroupRequest) error } @@ -301,13 +539,543 @@ type DirectorySyncUseCase interface { } ``` -### 5.3 會員狀態 +#### 5.2.2 Composite(可選;常用組合的便利包) + +> Composite 內部只呼叫 Atomic primitives + library / notifier,**不持有任何不可由 atomic 推出的副作用**。 +> Logic 可選擇用 composite(簡單情況)或直接組 atomic(特殊需求)。 + +```go +// 業務 email / phone 驗證 = OTP.Generate + Notifier.Send + Profile.SetXxxVerified +type VerificationUseCase interface { + StartEmailVerify(ctx context.Context, tenantID, uid, target string) (*OTPChallengeDTO, error) + ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error + StartPhoneVerify(ctx context.Context, tenantID, uid, target string) (*OTPChallengeDTO, error) + ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error +} + +// Step-up = (TOTP.VerifyCode 或 OTP.Generate+Notifier.Send/OTP.Verify) + auth.StepUpToken.Issue +type StepUpUseCase interface { + Start(ctx context.Context, tenantID, uid string, req *StepUpStartRequest) (*StepUpChallengeDTO, error) + Confirm(ctx context.Context, tenantID, uid string, req *StepUpConfirmRequest) (stepUpToken string, err error) +} +``` + +#### 5.2.3 Request / DTO 草案 + +```go +// Provisioning +type EnsureFromOIDCRequest struct { + TenantID string + ZitadelSub string + Email string + EmailVerified bool // 來自 id_token claim;OIDC 通常 true + DisplayName string + Locale string + RawClaims map[string]any +} + +type EnsureFromLDAPRequest struct { + TenantID string + ExternalID string // objectGUID / entryUUID + LDAPDN string + Username string + Email string + DisplayName string + Groups []string + Source enum.RoleSource // ldap_sync | ldap_jit +} + +type EnsureFromSCIMRequest struct { + TenantID string + ExternalID string // SCIM externalId(不等於 UID) + UserName string + Email string + DisplayName string + Active bool + RawPayload map[string]any +} + +// Platform registration +type CreatePlatformMemberRequest struct { + TenantID string + Email string + PasswordHash string // 若使用 ZITADEL local user,留空(由 ZITADEL 管) + DisplayName string + Language string + // 不會立即 active;新建 member.status = unverified +} + +// OTP +type GenerateOTPRequest struct { + TenantID string + Purpose enum.OTPPurpose // registration_email | business_email | business_phone | step_up | password_reset | ... + Identifier string // 通常是 uid;註冊期 uid 尚未存在時可用 hash(email) + Length int // 0 = 用 config 預設(6) + TTLSeconds int // 0 = 用 config 預設(300) +} + +type OTPChallengeDTO struct { + ChallengeID string + Code string // 僅 Generate 時回傳一次(明碼);caller 自負投遞 + ExpiresIn int +} + +type VerifyOTPRequest struct { + TenantID string + ChallengeID string + Code string + Purpose enum.OTPPurpose // 必填,防 challenge 被借用到其他用途 +} + +// Step-up +type StepUpStartRequest struct { + TenantID string + UID string + Action enum.StepUpAction + PreferChannel enum.Channel // 可選:totp | sms | email;不指定則依 §5.6 優先序 +} + +type StepUpChallengeDTO struct { + ChallengeID string // TOTP 無 challenge_id 也可回固定值;Confirm 時不會去比對 + Channel enum.Channel + ExpiresIn int +} + +type StepUpConfirmRequest struct { + TenantID string + UID string + ChallengeID string + Code string + Action enum.StepUpAction +} +``` + +#### 5.2.4 Enum 草案 + +```go +// member/enum/otp_purpose.go +type OTPPurpose string +const ( + OTPPurposeRegistrationEmail OTPPurpose = "registration_email" + OTPPurposeBusinessEmail OTPPurpose = "business_email" + OTPPurposeBusinessPhone OTPPurpose = "business_phone" + OTPPurposeStepUp OTPPurpose = "step_up" + OTPPurposePasswordReset OTPPurpose = "password_reset" // 預留 +) + +// auth/enum/step_up_action.go(已存在於 §5.6,集中宣告於此) +type StepUpAction string +const ( + StepUpChangeBusinessEmail StepUpAction = "change_business_email" + StepUpChangeBusinessPhone StepUpAction = "change_business_phone" + StepUpDeleteMember StepUpAction = "delete_member" + StepUpTenantAdminForceStatus StepUpAction = "tenant_admin_force_status" + StepUpRevokeAllSessions StepUpAction = "revoke_all_sessions" + StepUpDisableTOTP StepUpAction = "disable_totp" +) +``` + +### 5.3 會員生命週期狀態 | 狀態 | 語意 | 副作用 | |------|------|--------| -| `unverified` | 尚未完成業務驗證 | — | +| `unverified` | **僅平台原生註冊**會出現:member 已建立,但註冊 email 尚未通過 OTP 驗證 | 不簽 token、不可登入;逾期由 cron `AbortPending` 清理 | | `active` | 正常使用 | — | -| `suspended` | 停權 | `auth.RevokeAllForUser` | +| `suspended` | 停權(管理員操作 / 風控) | `auth.RevokeAllForUser`(`INCR auth_gen`) | +| `deleted` | 軟刪除 | 清 cache、撤銷 token、ZITADEL disable;30 天後匿名化(§5.7) | + +> 來自 OIDC / LDAP / SCIM 的 member **直接建為 `active`**(email 由來源 IdP 已驗證);只有 platform-native 註冊會經過 `unverified`。 +> 業務 email / phone 驗證以獨立旗標(`BusinessEmailVerified` / `BusinessPhoneVerified`)表示,與生命週期狀態解耦。 + +#### Member 欄位 Source of Truth(已決策) + +| 欄位類別 | 範例 | SoT | 行為 | +|---------|------|-----|------| +| 身份識別 | `zitadel_sub`、`ZitadelEmail`、`DisplayName`(IdP)、ZITADEL `status` | **ZITADEL** | 每次 token exchange / webhook 同步;Gateway 不可改寫 | +| 業務資料 | `BusinessEmail/Phone(+Verified)`、`Language`、`Currency`、`Avatar`、`Preferences` | **Gateway** | 業務 API 寫;不回推 ZITADEL | +| Provisioning 來源 | `external_id`、`ldap_dn`、SCIM 群組成員資格 | **來源系統**(LDAP/SCIM) | sync replace;Gateway 不直接編輯 | + +> 推論:`Member.Origin` 標主來源;對應「Provisioning」欄位類別的可寫範圍。Gateway UI 改業務欄位永遠可行;改身份/Provisioning 欄位需走來源系統。 + +### 5.4 業務級驗證模型(已決策) + +```go +// Member 既有 + 本節新增欄位 +type Member struct { + TenantID string + UID string + ZitadelUserID string // ZITADEL sub(OIDC / LDAP IdP / platform local user 都會有) + ZitadelEmail string // 來源 IdP 提供的登入 email + DisplayName string + Avatar string + Phone string + Language string + Currency string + + Status enum.MemberStatus // unverified | active | suspended | deleted + Origin enum.MemberOrigin // platform_native | oidc | ldap | scim + PasswordHash string // 平台原生且不用 ZITADEL local user 時才填;其餘留空 + + BusinessEmail string // 業務 email(可與 ZitadelEmail 不同) + BusinessEmailVerified bool + BusinessEmailVerifiedAt int64 + BusinessPhone string + BusinessPhoneVerified bool + BusinessPhoneVerifiedAt int64 + + TOTPEnrolled bool + TOTPSecretCipher string + TOTPEnrolledAt int64 + TOTPBackupCodesHash []string + + CreateAt int64 + UpdateAt int64 + DeletedAt int64 // soft delete 時間 + AnonymizedAt int64 // 匿名化時間 +} +``` + +> **Origin** 取值: +> - `platform_native`:Gateway 平台原生註冊(搭配 ZITADEL local user 或 Gateway 自管密碼) +> - `oidc`:Social / ZITADEL Hosted UI 等 IdP 來的 +> - `ldap`:透過 ZITADEL LDAP IdP 或 Directory Sync +> - `scim`:HR / Entra / Okta 推送 + +> `Member.Origin` 決定 Profile 欄位 UI 可寫範圍: +> - `zitadel_local`:身份欄位(IdP email/name)唯讀,需走 ZITADEL UI 改;業務欄位可寫 +> - `ldap`:身份 + provisioning 欄位皆唯讀(由 Directory Sync 維護);業務欄位可寫 +> - `scim`:身份 + provisioning 欄位由 SCIM Provider 推送,唯讀;業務欄位可寫 +> +> `UserRole.Source` 仍維持 `manual / zitadel / ldap / scim`,影響 sync replace 範圍(見 §6.10)。兩者各司其職。 + +| 欄位 | 來源 | 用途 | +|------|------|------| +| `ZitadelEmail`(既有) | OIDC claim | 登入帳號識別,不做業務守門 | +| `BusinessEmail` | 業務 API 綁定 + OTP | 業務通知、業務守門條件 | +| `BusinessPhone` | 業務 API 綁定 + OTP | SMS 通知、Step-up MFA 通道 | + +**Verification Challenge(不入 Mongo,僅存 Redis,TTL 5min):** + +```go +type VerificationChallenge struct { + TenantID string + UID string + Kind enum.VerifyKind // email | phone | step_up + Target string // email/phone 目的地;step_up 為 action + CodeHash string // bcrypt(otp) + AttemptCnt int // 失敗次數,超過 MaxAttempts → 鎖 + ExpireAt int64 // epoch ms + CreateAt int64 +} +``` + +### 5.5 OTP 投遞(已決策:透過 Notification Module) + +業務 / step-up OTP **一律走** `notification.NotifierUseCase`,**不**在 member 模組直接接 provider SDK。Notification module 統一處理 provider 切換、模板、idempotency、重試、audit(見 §11)。 + +```go +// member.VerificationUseCase 內呼叫 +nu.Notifier.Send(ctx, ¬ification.SendRequest{ + TenantID: tenantID, + UID: uid, + Channel: enum.ChannelEmail, + Kind: enum.NotifyVerifyEmail, + Target: targetEmail, + Locale: member.Language, + Data: map[string]any{"code": otp, "expires_in": 300}, + IdempotencyKey: challengeID, // 同 challenge 不會重發 + DoNotPersistBody: true, // OTP 不入 notification.body + Severity: enum.SeverityInfo, +}) +``` + +- **OTP 規格**:6 位數、TTL 5min、bcrypt 儲存(不存明碼)、重發冷卻 60s、單一 challenge 失敗 5 次直接鎖 +- **Rate Limit**: + - `verify:rate:{tenant}:{uid}:{kind}` SETNX TTL=60s(重發保護) + - `verify:daily:{tenant}:{uid}:{kind}` INCR TTL=24h(單日上限,預設 10 次) +- **Audit**:Start / Confirm 進 audit log(Notification 自己也會記送達狀態,兩者互補) +- **Provider 切換不影響 member 模組**:換 SendGrid → SES、Twilio → SNS 只動 `etc/gateway.yaml` 與 library 實作 + +### 5.6 Step-up MFA(已決策:啟用) + +**用途**:高風險業務操作前的二次驗證,與 ZITADEL 身份 MFA **互不取代**。 + +#### 高風險 Action 清單(enum) + +| Action | 目標 API | +|--------|---------| +| `change_business_email` | `PATCH /members/me/business-email` | +| `change_business_phone` | `PATCH /members/me/business-phone` | +| `delete_member` | `DELETE /members/me` | +| `tenant_admin_force_status` | `PATCH /members/:uid/status`(管理員停權他人)| +| `revoke_all_sessions` | `POST /auth/revoke-all` | +| `disable_totp` | `DELETE /members/me/totp` | + +> 後續可由 tenant 透過設定加白名單;初版 platform-wide enum,禁止任意字串。 + +#### Step-up 通道(已決策) + +優先序:**TOTP > SMS > Email** + +| 通道 | 條件 | 為何優先 | +|------|------|---------| +| **TOTP**(Google Authenticator) | 使用者已 `enroll_totp` 完成(§5.8) | 不依賴外部 provider、不會被 SIM swap、無頻寬限制、零成本 | +| **SMS** | `BusinessPhoneVerified = true` | 比 email 即時、不易被攔截 | +| **Email** | `BusinessEmailVerified = true` | 後備通道 | + +Start 時由 `StepUpUseCase` 依使用者狀態挑通道;若使用者要求其他通道(如不想用 TOTP)可在 request 帶 `prefer_channel` 覆寫,但仍需該通道已驗證。 + +#### 流程 + +``` +1. Client → POST /auth/step-up/start { action, prefer_channel?: "totp" } + - 解析使用者已可用通道;挑選優先通道 + - 若選 totp:不寄 OTP,直接回 challenge_id;code 由使用者從 app 取 + - 若選 sms/email:生成 6 碼 OTP、bcrypt 儲存、透過 NotifierUseCase.Send 寄出 + ← { challenge_id, channel: "totp"|"sms"|"email", expires_in: 300 } + +2. Client → POST /auth/step-up/confirm { challenge_id, code, action } + - totp:member.TOTPUseCase.VerifyCode(uid, code, window=±1) + - sms/email:bcrypt 比對 challenge code;失敗 INCR AttemptCnt + - 成功 → auth.StepUpTokenUseCase.Issue(tenant, uid, action) → 短壽 JWT + ← { step_up_token, token_type: "step_up", expires_in: 300 } + +3. Client → PATCH /members/me/business-email { ... } + Header: X-Step-Up-Token: + - Logic 層: + a. Casbin enforce 通過(基本權限) + b. StepUpTokenUseCase.Verify(token, expectedAction="change_business_email", tenant, uid) + c. SETNX auth:stepup:used:{jti}=1,已用過 → 拒絕 + d. 執行業務邏輯 +``` + +#### 守門點 + +- Logic 層守門:**Casbin allow 後**再驗 step-up;雙閘門 +- Header 名稱:`X-Step-Up-Token` +- 失敗回傳:`403 step_up_required` + `{ required_action: "change_business_email", available_channels: ["totp","sms"] }`,前端依此跳 step-up 流程 + +### 5.7 帳號刪除與匿名化(已決策) + +``` +T0: DELETE /api/v1/members/me (Step-up: delete_member) + 1. status = deleted, deleted_at = now + 2. auth.RevokeAllForUser(INCR auth_gen + 拉 jti pair 黑名單) + 3. ZITADEL Mgmt.DeactivateUser + 4. 清 member:profile / member:sub cache + 5. audit log (actor, ip, ua, step_up_jti) + +T+30 天: cron `member_anonymize_worker` + 匿名化欄位(覆寫為 hash 或固定 placeholder): + ZitadelEmail → "deleted:{uid}@anonymized.local" + DisplayName → "Deleted User" + Avatar → "" + Phone → "" + BusinessEmail → "" + BusinessPhone → "" + BusinessEmail/PhoneVerified → false + TOTPSecretCipher → "" + TOTPBackupCodesHash → nil + external_id, ldap_dn → "" + zitadel_sub → "deleted:{uid}" # 維持 identities 唯一索引 + 保留欄位(不可改 / 審計用): + tenant_id, uid, status=deleted, deleted_at, anonymized_at, created_at + 寫 audit log: action=member.anonymized +``` + +- **不可逆**;30 天內可由租戶 admin 還原(`status=deleted → active`,恢復 cache,但 ZITADEL 帳號需另行啟用) +- audit log 不受匿名化影響(actor uid 仍保留,便於追溯) +- 匿名化後 SCIM `Users.{id}` 仍可查到(回傳 `active=false` + 匿名 payload),不回 404,以維持 client 的 reconciliation + +### 5.8 TOTP(Authenticator App,已決策:啟用) + +業務級 TOTP,Gateway **自己存 secret**,與 ZITADEL 身份級 TOTP **獨立**(兩個獨立綁定,使用者首次 setup 需各掃一次 QR)。 + +> 為什麼分開?ZITADEL TOTP 是登入用、secret 在 ZITADEL;Gateway step-up TOTP 用於業務操作、secret 在 Gateway,避免 Gateway 對 ZITADEL 私有資料的依賴與耦合。 + +#### Member 欄位(補充 §5.4) + +```go +type Member struct { + // ... 既有欄位 + TOTPEnrolled bool + TOTPSecretCipher string // AES-GCM(secret, KEK),AES-256;KEK 走 KMS / secret manager + TOTPEnrolledAt int64 + TOTPBackupCodesHash []string // bcrypt(code),10 組一次性備援碼,用過即抹除 +} +``` + +> Secret 必須對稱加密儲存,**禁止**明碼或單純 base32。KEK 走 KMS / Vault;rotation 時逐筆 re-encrypt(背景 worker)。 + +#### UseCase 介面(補充 §5.2) + +```go +type TOTPUseCase interface { + // 產生 secret + otpauth URL + 10 組 backup codes(首次 enroll;尚未啟用) + StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error) + // 使用者掃 QR、輸入第一組 code → 確認 → 標 TOTPEnrolled = true + ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) // 回 backup_codes(明碼,只回一次) + // step-up 用:驗一個 code(含 backup code) + VerifyCode(ctx context.Context, tenantID, uid, code string) error + // 解除綁定(需 step-up = disable_totp) + Disable(ctx context.Context, tenantID, uid string) error + // 重新產生 backup codes(需 step-up) + RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) +} +``` + +#### 流程 + +``` +A. Enroll + Client → POST /api/v1/members/me/totp/enroll-start + 1. 若已 TOTPEnrolled = true → 409 already_enrolled + 2. 生成 32-byte random secret → base32 + 3. otpauth_url = "otpauth://totp/{Issuer}:{tenant_slug}:{uid}?secret={base32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30" + 4. 暫存於 Redis(不入 Mongo,避免半完成的 secret 散落): + totp:enroll:{tenant}:{uid} = {secret_cipher} TTL 10min + ← { otpauth_url, qr_png_base64 } + + Client → POST /api/v1/members/me/totp/enroll-confirm { code } + 1. 從 Redis 取暫存 secret + 2. VerifyTOTP(secret, code, window=±1) → 失敗則 400 invalid_code + 3. member.TOTPSecretCipher = secret_cipher + member.TOTPEnrolled = true + member.TOTPEnrolledAt = now + 4. 生成 10 組 backup code (random hex)、bcrypt 後存 TOTPBackupCodesHash + 5. DEL totp:enroll:* + 6. audit log + ← { backup_codes: [...10 組明碼,僅此一次回傳] } + +B. Verify(step-up 共用) + StepUpUseCase.Confirm 內: + VerifyTOTP(decryptedSecret, code, window=±1) OR matchBackupCode(code) + 若用 backup code 命中 → 從 TOTPBackupCodesHash 移除該筆(單次性) + +C. Disable + Client → DELETE /api/v1/members/me/totp + Header: X-Step-Up-Token: + 1. 清 TOTPSecretCipher、TOTPEnrolled=false、TOTPBackupCodesHash=nil + 2. audit log +``` + +#### TOTP 演算法與參數 + +- **RFC 6238**(SHA1 / 30s period / 6 digits),相容 Google Authenticator、Authy、1Password、Microsoft Authenticator +- `window = ±1`:允許前後一個 30s 區間,容忍時鐘漂移 +- Replay 保護:成功使用的 `(uid, code, timestep)` 寫入 `totp:used:{tenant}:{uid}:{timestep}` SETNX TTL=90s;同一 code 二次出現直接拒 +- Backup code:10 組、12 字 hex(48-bit entropy)、bcrypt cost 10、明碼僅 enroll 時回傳一次 + +#### API(補充 §7.2) + +| Method | Path | 說明 | Step-up | +|--------|------|------|---------| +| POST | `/api/v1/members/me/totp/enroll-start` | 取 otpauth URL + QR | — | +| POST | `/api/v1/members/me/totp/enroll-confirm` | 驗第一組 code,啟用 + 回 backup codes | — | +| GET | `/api/v1/members/me/totp` | 取 TOTP 狀態(enrolled? backup 剩餘數) | — | +| POST | `/api/v1/members/me/totp/backup-codes` | 重產 backup codes | ✅ `disable_totp` | +| DELETE | `/api/v1/members/me/totp` | 解除綁定 | ✅ `disable_totp` | + +### 5.9 UseCase 編排示例(純概念;handler / API 暫不實作) + +> 展示 atomic primitives 可任意組合的邏輯流。**logic 層尚未實作**;本節僅證明介面契約可支撐預期業務。 + +#### Case A:平台原生註冊 + Email OTP 驗證(未來路徑) + +```go +// 1) 建立 unverified member(不寄信、不發 token) +m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{ + TenantID: tenantID, Email: email, DisplayName: name, +}) + +// 2) 產生 OTP(atomic、purpose-agnostic) +chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ + TenantID: tenantID, + Purpose: OTPPurposeRegistrationEmail, + Identifier: m.UID, +}) + +// 3) 投遞 OTP(atomic;caller 控制 channel / template) +notifier.Send(ctx, &SendRequest{ + TenantID: tenantID, + UID: m.UID, + Channel: ChannelEmail, + Kind: NotifyVerifyRegistrationEmail, + Target: email, + Data: map[string]any{"code": chal.Code, "expires_in": chal.ExpiresIn}, + IdempotencyKey: chal.ChallengeID, + DoNotPersistBody: true, +}) + +// (使用者收信、輸入 code → 後端走以下兩步) + +// 4) 驗證 OTP(atomic) +_ = mOTP.Verify(ctx, &VerifyOTPRequest{ + TenantID: tenantID, ChallengeID: chal.ChallengeID, + Code: userCode, Purpose: OTPPurposeRegistrationEmail, +}) + +// 5) 啟用(atomic):unverified → active +_ = mLifecycle.Activate(ctx, tenantID, m.UID) +``` + +#### Case B:OIDC(Social / ZITADEL Hosted UI)登入 — 不需 OTP + +```go +m, _ := mProv.EnsureFromOIDC(ctx, &EnsureFromOIDCRequest{ + TenantID: tenantID, + ZitadelSub: claims.Sub, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + DisplayName: claims.Name, +}) +// 直接 active;之後 auth.IssueTokenPair +``` + +#### Case C:LDAP IdP 首次登入 JIT — 不需 OTP + +```go +m, _ := mProv.EnsureFromLDAP(ctx, &EnsureFromLDAPRequest{ + TenantID: tenantID, ExternalID: ldapUUID, LDAPDN: dn, + Username: username, Email: email, DisplayName: name, + Groups: groups, Source: RoleSourceLDAPJIT, +}) +``` + +#### Case D:SCIM Create User — 不需 OTP + +```go +m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{ + TenantID: tenantID, ExternalID: scimExternalID, + UserName: username, Email: email, Active: true, RawPayload: rawJSON, +}) +``` + +#### Case E:已登入 user 改綁業務 email(atomic 直組 vs composite) + +```go +// 路徑 1:直接組 atomic(精細控制時用) +chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ + TenantID: tenantID, Purpose: OTPPurposeBusinessEmail, Identifier: uid, +}) +notifier.Send(ctx, &SendRequest{ + Channel: ChannelEmail, Kind: NotifyVerifyBusinessEmail, + Target: newEmail, Data: map[string]any{"code": chal.Code}, + IdempotencyKey: chal.ChallengeID, DoNotPersistBody: true, +}) +_ = mOTP.Verify(ctx, &VerifyOTPRequest{ + TenantID: tenantID, ChallengeID: chal.ChallengeID, + Code: userCode, Purpose: OTPPurposeBusinessEmail, +}) +_ = mProfile.SetBusinessEmailVerified(ctx, tenantID, uid, newEmail) + +// 路徑 2:用 composite(簡單情況走這個就好) +chal, _ := mVerification.StartEmailVerify(ctx, tenantID, uid, newEmail) +// ... +_ = mVerification.ConfirmEmailVerify(ctx, tenantID, uid, chal.ChallengeID, userCode) +``` + +> 每個 atomic 動作獨立可呼叫、獨立 audit、獨立失敗重試。Logic 自行決定組合與順序。 --- @@ -323,12 +1091,12 @@ type DirectorySyncUseCase interface { | 能力 | 說明 | |------|------| | **Permission Tree** | 全局權限樹(平台 seed),父子節點繼承;父節點關閉則子節點不可用 | -| **Casbin RBAC** | 以 `(role, http_path, http_method)` 做 API 授權;path 支援 `keyMatch2` 萬用字元 | +| **Casbin RBAC** | 以 `(tenant_id, role_key, http_path, http_method)` 做 API 授權;path 支援 `keyMatch2` 萬用字元 | | **B2B 自定義 Role** | 每個租戶建立自訂 Role,從全局 Catalog **勾選** Permission(不可自創 Permission 字串) | -| **UserRole** | 租戶 + uid + role;支援多角色(JWT 帶 role names,Casbin 逐一檢查) | +| **UserRole** | 租戶 + uid + role;支援多角色(以 immutable role key 做 Casbin subject) | | **RolePermission** | 勾選子權限時自動補齊父權限 ID(沿用 permission-server 的 `getFullParentPermissionIDs`) | | **Policy 同步** | MongoDB → Casbin Policy → Redis;定時 `LoadPolicy` + 變更時觸發 reload | -| **外部映射** | ZITADEL Role / LDAP Group / SCIM Group → 租戶內部 Role | +| **外部映射** | ZITADEL Role / LDAP Group / SCIM Group → 租戶內部 Role.Key | | **細粒度擴展** | 同一 API 可掛 `.plain_code` 子權限(如明碼查詢),沿用舊設計 | ### 6.2 與 app-cloudep-permission-server 對照 @@ -340,21 +1108,22 @@ type DirectorySyncUseCase interface { | `entity.Permission` | 沿用 + `tenant_id` 不適用(全局 Catalog) | Permission 為平台級 | | `entity.Role.ClientID` | `Role.TenantID` | 租戶隔離 | | `entity.Role.UID` | `Role.CreatorUID` | 建立者,可選 | -| `entity.Role.Name` | `Role.Name` | **Casbin policy 的 role 欄位** | +| `entity.Role.Name` | `Role.DisplayName` | 顯示名稱,可改名 | +| — | `Role.Key` | **Casbin policy 的 role 欄位**,租戶內唯一且不可改 | | `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | 沿用 | | `PermissionTree` | `usecase/permission_tree.go` | 沿用 | -| `AdminRoleUID` / `GodDog` | `PlatformSuperAdminUID` | 平台超級管理員 bypass | +| `AdminRoleUID` / `GodDog` | `PlatformAdminRoleKey` + allowlist | 平台超級管理員 bypass,需 audit | | `permission.Type` | `enum.PermissionType` | `BackendUser` / `FrontendUser` | ### 6.3 核心概念 ``` Permission(全局樹) 平台定義,含 name / http_path / http_method / parent / status / type -Role(租戶自定義) 租戶建立的命名角色,如 sales_supervisor、tenant_admin +Role(租戶自定義) 租戶建立的角色;display_name 可改,key 不可改,如 sales_supervisor、tenant_admin RolePermission Role ↔ Permission ID 多對多;勾選時自動補父節點 UserRole uid ↔ Role;一 user 可多 role -RoleMapping 外部 Group/Role → 內部 Role.Name -Casbin Policy p, {role.Name}, {http_path}, {http_method}, {permission.Name} +RoleMapping 外部 Group/Role → 內部 RoleID / Role.Key +Casbin Policy p, {tenant_id}, {role_key}, {http_path}, {http_method}, {permission.Name} ``` ### 6.4 Permission Entity(全局 Catalog) @@ -363,18 +1132,22 @@ Casbin Policy p, {role.Name}, {http_path}, {http_method}, {permission.Nam ```go type Permission struct { - ID primitive.ObjectID - Parent string // 父權限 ID(ObjectID hex);空 = 掛 root - Name string // 唯一語意名,dot notation,如 member.info.select - HTTPMethod string // GET / POST / PATCH / DELETE / PUT;分類節點可為空 - HTTPPath string // 如 /api/v1/members/*;分類節點可為空 - Status enum.Status // open | close - Type enum.PermissionType // backend_user | frontend_user(後台 / 前台菜單) - CreateAt int64 - UpdateAt int64 + ID primitive.ObjectID + Parent string // 父權限 ID(ObjectID hex);空 = 掛 root + Name string // 唯一語意名,dot notation,如 member.info.select + HTTPMethods string // 單值如 "GET",或 regex 如 "GET|POST|PATCH";分類節點為空 + HTTPPath string // 如 /api/v1/members/*(keyMatch2 pattern);分類節點為空 + Status enum.Status // open | close + Type enum.PermissionType // backend_user | frontend_user(後台 / 前台菜單) + CreateAt int64 + UpdateAt int64 } ``` +> **`Permission.Name` 一旦建立不可改名**(被 RolePermission、UI i18n 鍵、Casbin policy.name 欄位引用)。 +> 廢棄走 `status=close`;新名稱另建新 leaf。重命名要走資料遷移腳本。 +> **`HTTPPath` 限制**:避免裸 `*`;萬用路徑要明確標出資源根,例如 `/api/v1/members/*`,禁止 `/api/v1/*` 之類的廣域 pattern(防 keyMatch2 貪婪命中)。 + #### 命名規則(dot notation,與 permission-server 一致) ``` @@ -427,18 +1200,25 @@ system.management # 平台級 ```go type Role struct { - ID primitive.ObjectID - TenantID string // 租戶 ID(= 舊 ClientID) - Name string // 角色名稱,租戶內唯一;Casbin enforce 用此值 - CreatorUID string // 建立者 uid(= 舊 Role.UID,可選) - Status enum.Status // open | close - IsSystem bool // 系統 seed 的預設角色,B2B 可改 Permission 但不可刪除 Owner - CreateAt int64 - UpdateAt int64 + ID primitive.ObjectID + TenantID string // 租戶 ID(= 舊 ClientID) + Key string // immutable role key,租戶內唯一;Casbin enforce 用此值 + DisplayName string // 顯示名稱,可改名 + CreatorUID string // 建立者 uid(= 舊 Role.UID,可選) + Status enum.Status // open | close + IsSystem bool // 系統 seed 的預設角色,B2B 可改 Permission 但不可刪除 Owner + CreateAt int64 + UpdateAt int64 } -// Index: { tenant_id, name } unique +// Index: { tenant_id, key } unique ``` +> **`Role.Key` 規範**: +> - 格式:`^[a-z][a-z0-9_]{1,63}$` +> - 租戶內唯一;建立後**不可修改** +> - 禁止 `system.` / `platform_` 字首(保留給平台級 role) +> - rename 改 `DisplayName`,不影響 UserRole、RoleMapping、Casbin policy 與既有 token + #### B2B 自定義規則 1. 租戶可 **CRUD** 自訂 Role(`is_system=false`) @@ -449,7 +1229,7 @@ type Role struct { #### 預設 Role 模板(建立 B2B tenant 時 seed) -| Name | 說明 | 預設勾選(Permission Name) | +| Key | DisplayName | 預設勾選(Permission Name) | |------|------|----------------------------| | `tenant_owner` | 租戶擁有者 | 除 `system.*` 外全部 open 節點 | | `tenant_admin` | 租戶管理員 | member.*, permission.*, tenant.*, scim.* | @@ -462,7 +1242,7 @@ B2B 管理員範例: ``` 建立 Role:sales_supervisor 勾選:member.admin.list, member.admin.read -指派:POST /permissions/users/{uid}/roles { "role_name": "sales_supervisor" } +指派:POST /permissions/users/{uid}/roles { "role_id": "..." } → RolePermission.Create → getFullParentPermissionIDs 自動補 parent → LoadPolicy 刷新 Casbin ``` @@ -482,41 +1262,38 @@ type UserRole struct { // Index: { tenant_id, uid } type RolePermission struct { + TenantID string RoleID string PermissionID string CreateAt int64 UpdateAt int64 } -// Index: { role_id, permission_id } unique +// Index: { tenant_id, role_id, permission_id } unique ``` -> 舊 permission-server 的 UserRole 為一 user 一 role(Update 覆蓋);新設計**支援多角色**,Middleware 對每個 role name 做 Casbin enforce,任一 allow 即通過。 +> 舊 permission-server 的 UserRole 為一 user 一 role(Update 覆蓋);新設計**支援多角色**,Middleware 對每個 immutable role key 做 Casbin enforce,任一 allow 即通過。 ### 6.7 Casbin RBAC(核心授權引擎) -#### 模型檔 `etc/rbac.conf`(沿用 permission-server) +#### 模型檔 `etc/rbac.conf`(Gateway 多租戶版) ```ini [request_definition] -r = role, path, method +r = tenant, role, path, method [policy_definition] -p = role, path, methods, name +p = tenant, role, path, methods, name [policy_effect] e = some(where (p.eft == allow)) -[role_definition] -g = _, _ - [matchers] -m = g(r.role, p.role) && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) \ - || r.role == "${PlatformSuperAdminUID}" +m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) ``` - **`keyMatch2`**:支援 `/api/v1/members/*` 萬用 path - **`regexMatch`**:支援 `GET|POST` 多 method 寫在同一 policy -- **SuperAdmin bypass**:平台維運 uid 全放行(對應舊 `GodDog`) +- **SuperAdmin bypass**:不放在 Casbin matcher;由 Middleware 先驗證 platform role / allowlist 後短路,並寫入 audit log #### Policy 載入(`RBACUseCase.LoadPolicy`) @@ -524,16 +1301,16 @@ m = g(r.role, p.role) && keyMatch2(r.path, p.path) && regexMatch(r.method, p.met 1. permissionRepo.GetAll → GeneratePermissionTree → filterOpenNodes 2. roleRepo.All(tenant_id) → 每個 role 取 rolePermissionRepo.Get 3. 對每個 (role, permission) 若 http_path + http_method 非空: - enforcer.AddPolicy(role.Name, permission.HTTPPath, permission.HTTPMethod, permission.Name) -4. adapter.SavePolicy → Redis List(casbin rules) + enforcer.AddPolicy(tenantID, role.Key, permission.HTTPPath, permission.HTTPMethods, permission.Name) +4. adapter.SavePolicy(tenant_id) → Redis List(tenant-scoped casbin rules) 5. enforcer.LoadPolicy() ``` #### 授權檢查(`RBACUseCase.Check`) ```go -// 輸入:roleName, requestPath, requestMethod -ok, policy, err := enforcer.EnforceEx(roleName, path, method) +// 輸入:tenantID, roleKey, requestPath, requestMethod +ok, policy, err := enforcer.EnforceEx(tenantID, roleKey, path, method) // 回傳 CheckRolePermissionStatus: // Allow: bool @@ -545,12 +1322,12 @@ ok, policy, err := enforcer.EnforceEx(roleName, path, method) | 觸發 | 動作 | |------|------| -| RolePermission 變更 | 該 tenant `LoadPolicy` | -| Permission status 變更(平台) | 全局 `LoadPolicy` | +| RolePermission 變更 | 該 tenant `LoadPolicy` + 權限快取失效 | +| Permission status 變更(平台) | 全局 `LoadAllPolicies` + 權限快取失效 | | 定時 cron(如 5min) | `SyncPolicy` 兜底 | | Gateway 啟動 | 初始 `LoadPolicy` | -Redis 儲存 Casbin rules:`permission:casbin:rules`(List of JSON `rbac.Rule`) +Redis 儲存 Casbin rules:`permission:casbin:rules:{tenant_id}`(List of JSON `rbac.Rule`)。全量載入時可掃描 tenant-scoped keys,或由 repository 依 MongoDB role/permission 重建。 ### 6.8 UseCase 介面 @@ -566,6 +1343,7 @@ type RBACUseCase interface { type CheckRequest struct { TenantID string UID string + RoleKey string // immutable Role.Key Path string // 實際請求 path Method string // 實際 HTTP method } @@ -597,17 +1375,17 @@ type RoleUseCase interface { // --- RolePermission --- type RolePermissionUseCase interface { - Get(ctx context.Context, roleID string) (enum.Permissions, error) // name → open/close - Create(ctx context.Context, roleID string, perms enum.Permissions) error - Delete(ctx context.Context, roleID string, perms enum.Permissions) error + Get(ctx context.Context, tenantID, roleID string) (enum.Permissions, error) // name → open/close + Replace(ctx context.Context, tenantID, roleID string, permNames []string) error // 全量取代 } // --- UserRole --- type UserRoleUseCase interface { GetByUID(ctx context.Context, tenantID, uid string) ([]UserRoleDTO, error) - Assign(ctx context.Context, tenantID, uid, roleID string) error + GetRoleKeys(ctx context.Context, tenantID, uid string) ([]string, error) // Middleware 用,走 cache + Assign(ctx context.Context, tenantID, uid, roleID string, source enum.RoleSource) error Revoke(ctx context.Context, tenantID, uid, roleID string) error - Replace(ctx context.Context, tenantID, uid string, roleIDs []string) error + Replace(ctx context.Context, tenantID, uid string, roleIDs []string, source enum.RoleSource) error // 全量取代「該 source」的指派 } // --- 外部映射 --- @@ -627,20 +1405,29 @@ type AuthorizationQueryUseCase interface { } ``` +> **跨租戶防呆**:所有 mutation usecase(Role*, RolePermission*, UserRole*, RoleMapping*)進入時必須驗證 target ID 屬於 `tenantID`;repository 查詢一律帶 `{tenant_id, _id}`,找不到回 `ErrRoleNotInTenant` / `ErrUserRoleNotInTenant`。 +> Logic 層**禁止**把 path 的 `:id` 直接丟 usecase 而不帶 `tenant_id`。 + ### 6.9 Middleware 授權流程 ``` -Request(JWT 已驗證) +Request(JwtRevokeMiddleware 已驗過 JWT + auth_gen) 1. 取 ctx.tenant_id, ctx.uid - 2. userRoleUC.GetByUID → []Role.Name - 3. 對每個 roleName: - rbacUC.Check(tenantID, uid, roleName, r.URL.Path, r.Method) - 4. 任一 Allow=true → 通過,注入 ctx.permission_name, ctx.plain_code - 5. 全否 → 403 Forbidden - 6. SuperAdmin uid → 直接通過 + 2. userRoleUC.GetRoleKeys → []Role.Key(走 perm:user_roles cache) + 3. 對每個 roleKey enforce(tenantID, roleKey, path, method); + 聚合所有 allow 結果為 []CheckResult + 4. 若無任何 allow → 403 Forbidden + 5. 聚合規則: + - PermissionNames = 所有 allow 命中的 permission.Name(去重) + - PlainCode = 對每個命中 permission,額外 enforce + (permission.Name + ".plain_code") 變體;任一通過 → true + 6. 注入 ctx.permission_names, ctx.plain_code ``` -Logic 層可讀 `ctx.plain_code` 決定是否回傳明碼欄位(沿用 permission-server 的 `PlainCode` 模式)。 +> **PlainCode 實作**:`*.plain_code` 與一般 leaf 一樣寫入 Casbin policy;Check 時主 permission 命中後,用同一 `(tenantID, roleKey, path, method)` 再做一次帶 `.plain_code` 的 EnforceEx。沒有 plain_code 變體 → false。 +> Logic 層讀 `ctx.plain_code` 決定是否回傳明碼欄位。 + +> **Platform Admin bypass** 由 `JwtRevokeMiddleware` 第 0 步處理(見 §4.6),不進這個流程。 ### 6.10 外部 Group / Role 映射 @@ -649,7 +1436,8 @@ type RoleMapping struct { TenantID string ExternalSource enum.RoleSource // zitadel | ldap | scim ExternalKey string // ZITADEL role / LDAP group DN / SCIM group id - InternalRole string // 租戶 Role.Name + InternalRoleID string // 租戶 Role._id hex + InternalRoleKey string // denormalized Role.Key,方便查詢與審計 } // Index: { tenant_id, external_source, external_key } unique ``` @@ -657,29 +1445,59 @@ type RoleMapping struct { | 來源 | ExternalKey 範例 | 映射到 | |------|------------------|--------| | ZITADEL | `org_admin` | `tenant_admin` | -| LDAP (AD) | `CN=CloudEP-Admins,OU=Groups,DC=acme,DC=com` | 租戶自訂 Role.Name | -| LDAP (OpenLDAP) | `cn=admins,ou=groups,dc=acme,dc=com` | 租戶自訂 Role.Name | -| SCIM Group | `group-uuid-xxx` | 租戶自訂 Role.Name | +| LDAP (AD) | `CN=CloudEP-Admins,OU=Groups,DC=acme,DC=com` | 租戶自訂 Role.Key | +| LDAP (OpenLDAP) | `cn=admins,ou=groups,dc=acme,dc=com` | 租戶自訂 Role.Key | +| SCIM Group | `group-uuid-xxx` | 租戶自訂 Role.Key | 由 B2B 租戶管理員在後台設定(需命中 `permission.role.write` 對應 API)。 +#### 外部來源同步規則(避免洗掉 manual 指派) + +`SyncFromZitadelClaims` / `SyncFromScimGroup` / `SyncFromLDAPGroups` 一律以 **`source` 維度**做局部全量取代: + +``` +UserRoleUC.Replace(tenantID, uid, roleIDs, source = zitadel) + → DELETE user_roles WHERE tenant_id=? AND uid=? AND source='zitadel' + → INSERT 新的 roleIDs(source='zitadel') + → source='manual' / 'scim' / 'ldap' 的指派不受影響 +``` + +> 跨來源衝突原則:UserRole 為「並集」,任一 source 指派的 role 即生效;revoke 必須指定 source。 + ### 6.11 權限變更生效 | 事件 | 動作 | |------|------| -| RolePermission Create/Delete | `LoadPolicy(tenant_id)` | +| RolePermission Create/Delete | `LoadPolicy(tenant_id)` + `perm:role_perms:*` 快取失效 | | Role Create/Update/Delete | `LoadPolicy(tenant_id)` | -| UserRole Assign/Revoke | 可選 `INCR auth:gen`(立即踢下线) | -| SCIM / LDAP Group 變更 | 更新 user_roles → `LoadPolicy` + `INCR auth_gen` | -| Permission status 變更(平台) | `LoadAllPolicies()` | +| UserRole Assign/Revoke | **`INCR auth:gen`** + `LoadPolicy(tenant_id)` | +| SCIM / LDAP Group 變更 | 更新 user_roles → `LoadPolicy` + **`INCR auth_gen`** | +| Permission status 變更(平台) | `LoadAllPolicies()` + 權限快取失效;若變更影響登入狀態再 batch `INCR auth_gen` | -### 6.12 B2C vs B2B 權限策略 +#### 多 Pod 同步機制(已決策) -| 租戶類型 | Role 自定義 | Permission 勾選 | -|----------|-------------|-----------------| -| **B2C** | 不可(只用 seed 模板) | 固定 | -| **B2B** | **完全自定義** | 從全局 Catalog 自由勾選 | -| **Hybrid** | B2B 部分可自定義 | 依 tenant 設定 | +``` +Channel: casbin:reload +Payload: { "tenant_id": "xxx", "ts": 1716120000000 } # tenant_id == "*" 代表全量 +``` + +- **即時通道**:Pub/Sub + - Writer:每次 `LoadPolicy(tenant_id)` 完成後 `PUBLISH casbin:reload {tenant_id}` + - Subscriber:每個 pod 啟動時 `SUBSCRIBE`;收到後在記憶體中 reload 對應 tenant 的 policy +- **兜底**:每 pod 啟動排程 `5min` 全量 `LoadAllPolicies()`;防 pub message 漏接(pod 啟動瞬間、Redis 連線抖動) +- **冪等**:reload 用單一 mutex per tenant;同時段多個 message 只觸發一次實際 IO +- **首次啟動**:pod 啟動先做一次 `LoadAllPolicies()`,再開始 SUBSCRIBE +- 設定:`Permission.PolicySyncInterval: 5m`、`Permission.PolicyReloadChannel: casbin:reload` + +### 6.12 B2C vs B2B 權限策略(已決策) + +| 租戶類型 | Role 自定義 | Permission 勾選 | API 限制 | +|----------|-------------|-----------------|----------| +| **B2C** | **不可**(唯讀 seed 模板) | 固定,不可改 | 禁止 `POST/PUT/DELETE /permissions/roles*` | +| **B2B** | **完全自定義** | 從全局 Catalog 自由勾選 | 完整 permission API | +| **Hybrid** | 依 tenant.type 欄位判斷 | B2B 段可自定義 | middleware 檢查 tenant 類型 | + +B2C 租戶建立時只 seed 固定 Role(如 `member`、`viewer`),**不提供** Role CRUD 與 Permission 勾選 API(Casbin 直接載入 seed 結果)。 --- @@ -687,40 +1505,56 @@ type RoleMapping struct { 檔案:`generate/api/` -### 7.1 auth.api(公開) +### 7.1 auth.api(公開 / 需 JWT 視 API 而定) -| Method | Path | 說明 | -|--------|------|------| -| POST | `/api/v1/auth/token/exchange` | ZITADEL token → CloudEP JWT | -| POST | `/api/v1/auth/token/refresh` | 刷新 JWT | -| POST | `/api/v1/auth/logout` | 登出(jti 黑名單) | +| Method | Path | 說明 | 鑑權 | +|--------|------|------|------| +| POST | `/api/v1/auth/token/exchange` | ZITADEL token → CloudEP JWT | 公開 | +| POST | `/api/v1/auth/token/refresh` | 刷新 JWT | 公開(帶 refresh) | +| POST | `/api/v1/auth/logout` | 登出(jti 黑名單) | JWT | +| POST | `/api/v1/auth/revoke-all` | 撤銷自己所有 session(INCR auth_gen) | JWT + Step-up `revoke_all_sessions` | +| POST | `/api/v1/auth/step-up/start` | 啟動 step-up MFA,寄 OTP | JWT | +| POST | `/api/v1/auth/step-up/confirm` | 確認 OTP → 簽發短壽 `step_up_token` | JWT | ### 7.2 member.api(需 JWT + Casbin) -| Method | Path | Casbin 命中 Permission(示例) | -|--------|------|-------------------------------| -| GET | `/api/v1/members/me` | `member.info.select` | -| PATCH | `/api/v1/members/me` | `member.info.update` | -| GET | `/api/v1/members` | `member.admin.list` | -| GET | `/api/v1/members/:uid` | `member.admin.read` | -| PATCH | `/api/v1/members/:uid` | `member.admin.update` | -| PATCH | `/api/v1/members/:uid/status` | `member.admin.status` | +| Method | Path | Casbin 命中 Permission(示例) | Step-up | +|--------|------|-------------------------------|---------| +| GET | `/api/v1/members/me` | `member.info.select` | — | +| PATCH | `/api/v1/members/me` | `member.info.update` | — | +| PATCH | `/api/v1/members/me/business-email` | `member.info.update` | ✅ `change_business_email` | +| PATCH | `/api/v1/members/me/business-phone` | `member.info.update` | ✅ `change_business_phone` | +| DELETE | `/api/v1/members/me` | `member.info.delete` | ✅ `delete_member` | +| POST | `/api/v1/members/me/verifications/email/start` | `member.info.update` | — | +| POST | `/api/v1/members/me/verifications/email/confirm` | `member.info.update` | — | +| POST | `/api/v1/members/me/verifications/phone/start` | `member.info.update` | — | +| POST | `/api/v1/members/me/verifications/phone/confirm` | `member.info.update` | — | +| GET | `/api/v1/members/me/totp` | `member.info.select` | — | +| POST | `/api/v1/members/me/totp/enroll-start` | `member.info.update` | — | +| POST | `/api/v1/members/me/totp/enroll-confirm` | `member.info.update` | — | +| POST | `/api/v1/members/me/totp/backup-codes` | `member.info.update` | ✅ `disable_totp` | +| DELETE | `/api/v1/members/me/totp` | `member.info.update` | ✅ `disable_totp` | +| GET | `/api/v1/members` | `member.admin.list` | — | +| GET | `/api/v1/members/:uid` | `member.admin.read` | — | +| PATCH | `/api/v1/members/:uid` | `member.admin.update` | — | +| PATCH | `/api/v1/members/:uid/status` | `member.admin.status` | ✅ `tenant_admin_force_status` | -> 授權由 **Casbin 比對實際 path + method** 決定,非硬編碼 permission 字串。 +> 授權由 **Casbin 比對實際 path + method** 決定,非硬編碼 permission 字串。 +> Step-up 欄為✅者需在 Header 帶 `X-Step-Up-Token`,且 token claim 的 `action` 必須與表列 action 一致(見 §5.6)。 ### 7.3 permission.api(需 JWT + Casbin) | Method | Path | 說明 | |--------|------|------| | GET | `/api/v1/permissions/catalog` | 全局 Permission Tree(open 節點) | -| GET | `/api/v1/permissions/me` | 當前用户的 permission name → status map | +| GET | `/api/v1/permissions/me` | 當前使用者的 permission name → status map | | GET | `/api/v1/permissions/roles` | 列出租戶 Role | | POST | `/api/v1/permissions/roles` | 建立 Role(B2B) | | PUT | `/api/v1/permissions/roles/:id` | 更新 Role | | DELETE | `/api/v1/permissions/roles/:id` | 刪除 Role | | GET | `/api/v1/permissions/roles/:id/permissions` | 取得 Role 勾選的 Permission | -| PUT | `/api/v1/permissions/roles/:id/permissions` | 更新 Role 勾選(PermissionTree 驗證 + 補 parent) | -| GET | `/api/v1/permissions/users/:uid/roles` | 查用户角色 | +| PUT | `/api/v1/permissions/roles/:id/permissions` | 全量取代 Role 勾選 `{ "permission_names": [...] }`(PermissionTree 驗證 + 補 parent) | +| GET | `/api/v1/permissions/users/:uid/roles` | 查使用者角色 | | POST | `/api/v1/permissions/users/:uid/roles` | 指派 Role `{ "role_id": "..." }` | | DELETE | `/api/v1/permissions/users/:uid/roles/:role_id` | 撤銷 Role | | GET | `/api/v1/permissions/role-mappings` | 外部 Group 映射列表 | @@ -738,14 +1572,19 @@ type RoleMapping struct { ### 7.5 scim.api(SCIM Bearer Token,非 JWT) +**已決策路由**:以 **`tenant_id`** 為 path 參數(不用子域名) + ``` /scim/v2/tenants/{tenant_id}/Users /scim/v2/tenants/{tenant_id}/Groups +/scim/v2/tenants/{tenant_id}/ServiceProviderConfig +/scim/v2/tenants/{tenant_id}/Schemas ``` 認證:`Authorization: Bearer {tenant_scim_token}`(hash 存於 tenant 設定) -SCIM 請求授權可透過 tenant 級 token 隱含 `scim:*`,或細分 scim users/groups permission。 +- `{tenant_id}` = ZITADEL `org_id`,與 JWT `tenant_id` 一致 +- SCIM 請求不走 CloudEP JWT;授權由 tenant 級 SCIM token + 可選 Casbin 細分 --- @@ -758,30 +1597,36 @@ Request → go-zero JWT 驗簽 → JwtRevokeMiddleware(jti 黑名單 + auth_gen) → TenantContextMiddleware(校驗 tenant_id 一致) - → CasbinRBACMiddleware(role names × path × method → Allow) + → CasbinRBACMiddleware(tenant_id × role_key × path × method → Allow) → handler → logic → usecase ``` ### 8.2 CasbinRBACMiddleware +> Platform Admin bypass 在前一層 `JwtRevokeMiddleware` 第 0 步處理(§4.6),此處不重複。 + ```go // 伪代码 -roles := userRoleUC.GetRoleNames(ctx, tenantID, uid) -for _, roleName := range roles { - result, _ := rbacUC.Check(ctx, &CheckRequest{ +roleKeys, _ := userRoleUC.GetRoleKeys(ctx, tenantID, uid) +var hits []rbac.CheckResult +for _, roleKey := range roleKeys { + res, _ := rbacUC.Check(ctx, &rbac.CheckRequest{ TenantID: tenantID, UID: uid, - RoleName: roleName, Path: r.URL.Path, Method: r.Method, + RoleKey: roleKey, Path: r.URL.Path, Method: r.Method, }) - if result.Allow { - ctx = withPermissionName(ctx, result.PermissionName) - ctx = withPlainCode(ctx, result.PlainCode) - next(w, r) - return + if res.Allow { + hits = append(hits, res) } } -// SuperAdmin bypass -if uid == cfg.PlatformSuperAdminUID { next(w, r); return } -httpx.Error(w, forbidden) +if len(hits) == 0 { + httpx.Error(w, forbidden) + return +} + +names, plain := aggregate(hits) // 去重 + PlainCode OR +ctx = withPermissionNames(ctx, names) +ctx = withPlainCode(ctx, plain) +next(w, r) ``` ### 8.3 SCIM API @@ -799,6 +1644,13 @@ Casbin 處理 **API 級** 授權。Logic 內可追加 **資源級** 判斷: - `member.info.select` vs 查他人:若 path 含 `:uid` 且 uid ≠ caller,需命中 `member.admin.read` - `PlainCode`:Logic 讀 `ctx.plain_code`,決定是否回傳明碼欄位 +- **Step-up 守門**(高風險 action): + 1. 從 Header `X-Step-Up-Token` 取 token + 2. `auth.StepUpTokenUseCase.Verify(token, expectedAction, tenantID, uid)` + - 檢 `typ == "step_up"`、`action == expectedAction`、`tenant_id` / `uid` 與 ctx 一致、未過期 + 3. `SETNX auth:stepup:used:{jti}=1`,已存在 → `403 step_up_replay` + 4. 全部通過 → 執行業務操作 + 5. 失敗 → `403 step_up_required` + `{ required_action: "" }` --- @@ -811,9 +1663,9 @@ Client → ZITADEL OIDC Login(含 LDAP IdP) Client → POST /auth/token/exchange { tenant_slug, id_token } 1. zitadel.VerifyIDToken 2. tenant.ResolveBySlug → 校驗 org_id - 3. member.EnsureMember → uid(如 ACME-ODWXGYBK) + 3. member.EnsureFromOIDC → uid(如 AMEX-10000000) 4. permission.SyncFromZitadelClaims → user_roles - 5. auth.IssueTokenPair(role names 逗號分隔, auth_gen) + 5. auth.IssueTokenPair(role keys 快照, auth_gen) Client ← { access_token, refresh_token, uid } ``` @@ -829,23 +1681,73 @@ Client → GET /api/v1/members/me (Bearer access_jwt) ### 9.3 B2B 自定義 Role + 勾選 Permission ``` -Tenant Admin → PUT /permissions/roles/{id}/permissions - { "member.admin.list": "open", "member.admin.read": "open" } - → RolePermissionUC.Create +Tenant Admin → PUT /api/v1/permissions/roles/{id}/permissions + { "permission_names": ["member.admin.list", "member.admin.read"] } + → RolePermissionUC.Replace(全量取代) → PermissionTree.getFullParentPermissionIDs(自動補 parent) - → RBACUC.LoadPolicy(tenant_id) + → RBACUC.LoadPolicy(tenant_id) + 廣播 reload(見 §6.11) -Tenant Admin → POST /permissions/users/{uid}/roles +Tenant Admin → POST /api/v1/permissions/users/{uid}/roles { "role_id": "..." } - → UserRoleUC.Assign + → UserRoleUC.Assign(tenantID, uid, roleID, source=manual) + → INCR auth_gen + DEL perm:user_roles cache ``` ### 9.4 停權 ``` -Admin → PATCH /members/:uid/status { status: "suspended" } - → member.UpdateStatus - → auth.RevokeAllForUser(INCR auth_gen) +Admin → PATCH /api/v1/members/:uid/status { status: "suspended" } + Header: X-Step-Up-Token: + 1. Casbin enforce 命中 member.admin.status + 2. Logic 驗 step_up_token + action 一致 + 3. member.UpdateStatus + 4. auth.RevokeAllForUser(INCR auth:gen:{tenant_id}:{uid}) +``` + +### 9.5 業務 Email 驗證 + +``` +Client → POST /api/v1/members/me/verifications/email/start { target: "biz@foo.com" } + 1. (可選) 檢查 target 未被同租戶其他 member 使用 + 2. 檢查 verify:rate:{tenant}:{uid}:email 不存在(60s 冷卻) + 3. 生成 6 碼 OTP → bcrypt 存 verify:otp:{tenant}:{uid}:email:{challenge_id} TTL 5min + 4. NotificationClient.Email.Send(target, template=VerifyEmail, data={code}) + 5. SETEX verify:rate:{tenant}:{uid}:email 60 + 6. audit log +Client ← { challenge_id, expires_in: 300 } + +Client → POST /api/v1/members/me/verifications/email/confirm { challenge_id, code } + 1. 讀 challenge;過期或失敗 5 次 → 拒絕 + 2. bcrypt compare;失敗 → INCR AttemptCnt → 拒絕 + 3. 成功 → member.BusinessEmail = target, BusinessEmailVerified = true, BusinessEmailVerifiedAt = now + 4. DEL challenge + 5. audit log +Client ← { verified: true } +``` + +> phone 流程同上,OTP 通道走 SMS Provider;template 為 `VerifyPhone`。 + +### 9.6 Step-up MFA + 改業務 Email + +``` +Client → POST /api/v1/auth/step-up/start { action: "change_business_email" } + 1. 從 ctx.uid 讀 member;要求 BusinessEmailVerified || BusinessPhoneVerified + 2. 選通道:優先 phone(如已 verified)否則 email + 3. 生成 OTP → 寄出(步驟同 §9.5) +Client ← { challenge_id, channel, expires_in: 300 } + +Client → POST /api/v1/auth/step-up/confirm { challenge_id, code, action: "change_business_email" } + 1. bcrypt 比對;驗 challenge.kind == step_up && target == action + 2. auth.StepUpTokenUseCase.Issue(tenant, uid, action) → JWT (typ=step_up, action, TTL 5min) +Client ← { step_up_token, token_type: "step_up", expires_in: 300 } + +Client → PATCH /api/v1/members/me/business-email { new_email: "new@foo.com" } + Header: X-Step-Up-Token: + 1. Middleware 通過(一般 JWT + Casbin) + 2. Logic step-up 守門(見 §8.4) + 3. 重設 BusinessEmailVerified = false,BusinessEmail = new_email + 4. 內部觸發 §9.5 對 new_email 重新發 OTP(或直接回 challenge_id 給前端) + 5. audit log(含舊 / 新 email、step_up jti、IP、UA) ``` --- @@ -856,7 +1758,7 @@ Admin → PATCH /members/:uid/status { status: "suspended" } | 路徑 | 適用 | 說明 | |------|------|------| -| **SCIM → ZITADEL → Gateway** | 有 HR / Entra ID / Okta | 企業推送用户 | +| **SCIM → ZITADEL → Gateway** | 有 HR / Entra ID / Okta | 企業推送使用者 | | **ZITADEL LDAP IdP** | 用戶登入時 JIT | 首次登入建立 member | | **Directory Sync Worker** | 無 SCIM 的 AD / OpenLDAP | 定時同步 + 離職偵測 | @@ -889,62 +1791,230 @@ type LDAPAttrMap struct { ### 10.3 SCIM -- `externalId` = Member UID(`ACME-ODWXGYBK`) +- **SCIM `id` = Gateway Member UID**(`AMEX-10000000`)— 已決策;人讀、跨系統一致,便於 audit/支援交叉查詢 +- SCIM `externalId` = 客戶端 IdP / HR 系統提供的外部識別(如 Okta user id、Entra object id、employee id) +- `externalId` 以 `{tenant_id, external_id}` 做 idempotent upsert key;不可假設客戶端知道 Gateway UID +- ZITADEL `sub`、Mongo `_id` 不對外曝露;ZITADEL `sub` 透過 SCIM Extension Schema `urn:cloudep:scim:2.0:User:zitadelSub` 提供查詢,便於企業端 troubleshoot - SCIM Groups PATCH → `permission.SyncFromScimGroup` - SCIM deactivate → `member.suspended` + `auth.RevokeAllForUser` ---- +### 10.4 Directory Sync 誤判保護(已決策) -## 11. 可讀 UID 設計 +| 機制 | 設定 | 行為 | +|------|------|------| +| 連續找不到才停權 | `MissingThreshold: 3`(連續 3 次 cron) | 計數於 `members.directory_missing_count`;恢復偵測即歸零 | +| 單次異動上限 | `MaxChangeRatio: 0.20` | 單次 sync 異動超過該租戶 active members 20% → **強制轉 dry-run** + 告警,需人工確認 | +| 首次部署 | `DryRunOnFirstSync: true` | 首次同步只記 diff log,**不寫 DB** | +| Dry-run 模式 | `DryRun: true / false` | 全程不影響 DB,只產出 diff 報表(admin API 可下載) | +| 軟刪(離職) | guardrail 全通過才 `status=suspended`(**不直接 deleted**) | `deleted` 需人工或專門 workflow | +| Sync window | `Window: 24h` | 預設每 24h;可 tenant override | +| 告警通道 | `AlertSink: ops_webhook / mail` | 觸發 dry-run / 高異動率 / 連續失敗時通知 | -### 11.1 格式 - -``` -{TenantPrefix}-{Body} - -範例:ACME-ODWXGYBK -``` - -- `TenantPrefix`:2~4 位大寫(來自 tenant.UIDPrefix / slug 縮寫) -- `Body`:6~8 位大寫字母(自訂字母表,排除易混淆字元) -- **不要** UUID、不要純數字、不要 base64 亂碼 - -### 11.2 字母表(沿用 invited_code 風格) - -``` -O D W X Y G B C H E F A Q I J L M N Z K P V R S T -(25 字符,無 0/O/1/I 混淆問題) -``` - -### 11.3 產生(Bucket 取號,支援單租戶 50 萬) - -``` -Redis: member:seq:{tenant_id} -每次 INCRBY 500 取一個 bucket -bucket 內 sequential 編碼 → Body -``` - -唯一索引:`{ tenant_id, uid }` unique +> Worker 啟動順序:拉 LDAP snapshot → 計算 diff → 跑 guardrail 檢查(threshold + ratio)→ commit 或轉 dry-run → 寫 audit log。 --- -## 12. 資料模型與索引 +## 11. Notification Module -### 12.1 Collections +路徑:`internal/model/notification/` + +獨立 model 模組,集中處理所有 **outbound 通訊**:Email、SMS、(預留)Push、Webhook。所有業務模組(member 業務驗證、auth step-up、tenant 系統通知、admin 警示等)**統一**透過 `NotifierUseCase` 發送,**不**直接 import provider SDK。 + +### 11.1 職責 + +- Provider 抽象:Email / SMS / Push / Webhook 可獨立替換 +- Template 渲染:含多語系(i18n)+ 變數注入 +- 同步發送與異步排程(idempotency + 重試 + DLQ) +- 通知紀錄:persist 到 Mongo(送達狀態、provider message id、retry 軌跡) +- Rate limit / 配額(防爆發、防濫用) +- Hook:供 audit log 與 metrics 攔截 + +### 11.2 模組邊界 + +``` +member / auth / tenant / admin + │ + ▼ (NotifierUseCase.Send / Enqueue) +notification ── repository (audit + outbox) + │ + ▼ (interface) +internal/library/notification/ + ├── email/ (sendgrid | ses | smtp 實作) + ├── sms/ (twilio | sns | smsapi 實作) + └── push/ (預留) +``` + +**library 層**:純 IO,封裝各家 SDK;**model 層**:流程、模板、retry、audit、idempotency。 + +### 11.3 介面 + +```go +type NotifierUseCase interface { + // 同步發送:取得結果與 provider id;失敗回 error + Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error) + // 異步排隊:寫 Mongo outbox + 入 channel,worker 拉走重試;高吞吐用 + Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error) + // 查詢單筆狀態 + Get(ctx context.Context, tenantID, notificationID string) (*NotificationDTO, error) +} + +type SendRequest struct { + TenantID string + UID string // 可為空(系統通知) + Channel enum.Channel // email | sms | push | webhook + Kind enum.NotifyKind // verify_email | verify_phone | step_up | system_alert | ... + Target string // 收件位址(email / phone / device_token / url) + Locale string // zh-tw | en-us,未指定走 tenant.default_locale + Data map[string]any // 模板變數 + Severity enum.Severity // info | warn | critical + IdempotencyKey string // 業務 key;同 key 不重發 + DoNotPersistBody bool // OTP 等敏感內容不入庫,只記 metadata +} +``` + +> **OTP 等敏感內容**:`DoNotPersistBody=true` → notification.body 留空,只記 channel/kind/target hash/provider_message_id/status,避免 audit DB 出現明碼 OTP。 + +### 11.4 Entity 與 Collection + +```go +// notification collection +type Notification struct { + ID primitive.ObjectID + TenantID string + UID string + Channel enum.Channel + Kind enum.NotifyKind + TargetHash string // sha256(target),避免明碼 PII + TemplateKey string // 對應 TemplateRegistry + Locale string + Provider string // "sendgrid" | "twilio" | ... + ProviderMessageID string + Status enum.NotifyStatus // pending | sent | failed | retrying | dropped + Attempts int + LastError string + IdempotencyKey string // 唯一索引 {tenant_id, kind, idempotency_key} + Severity enum.Severity + OccurredAt int64 + DeliveredAt int64 +} +``` + +**Template** 採 **in-code registry**(型別安全)+ provider 端模板 ID(如 SendGrid Dynamic Template): + +```go +var TemplateRegistry = map[enum.NotifyKind]TemplateSpec{ + enum.NotifyVerifyEmail: { + EmailProviderTemplateID: "d-xxxxxxxxxxxxx", // SendGrid + SMSText: "", + RequiredVars: []string{"code", "expires_in"}, + }, + enum.NotifyVerifyPhone: { + SMSText: "Your verification code is {code} (valid {expires_in}s)", + RequiredVars: []string{"code", "expires_in"}, + }, + enum.NotifyStepUpEmail: {...}, + enum.NotifyStepUpPhone: {...}, + enum.NotifySystemAlert: {...}, +} +``` + +### 11.5 Idempotency 與重試 + +- `IdempotencyKey` 唯一索引:`{TenantID, Kind, IdempotencyKey}` +- 重複 Send 同 key → 直接回上次結果(不重發給 provider) +- 異步 worker 失敗策略:指數退避 1s / 5s / 30s / 5min / 30min,最多 5 次;超過 → `status=dropped` + audit +- DLQ:失敗 5 次的紀錄保留在 `notification_dlq` collection,admin API 可手動 retry + +### 11.6 與業務模組的呼叫關係 + +| 呼叫者 | Kind | Channel | 模式 | +|--------|------|---------|------| +| `member.VerificationUseCase` | `verify_email` / `verify_phone` | email / sms | **同步**(要立即知道送達 / 失敗) | +| `member.StepUpUseCase` | `step_up_email` / `step_up_phone` | email / sms | **同步** | +| `member.AdminUseCase`(停權告知) | `account_suspended` | email | **異步** | +| `tenant.UseCase`(租戶建立完成) | `tenant_welcome` | email | **異步** | +| ops alert(高異動率 / DLQ 滿) | `ops_alert` | email / webhook | **同步**(critical) | + +> **OTP 必須同步**,否則 client 無法回報「OTP 已寄出」的明確錯誤;其他通知優先異步避免拖慢業務 API。 + +### 11.7 與 Audit Log 的關係 + +每筆 Notification 寫入時同步寫 audit log: + +``` +action = notification.sent | notification.failed | notification.dropped +actor = system 或 caller uid +target = { kind: notification, id: notification_id, channel, kind } +metadata = { provider, provider_message_id, target_hash } +``` + +audit log 不重複存 body(已決策 §20.1 critical 同步寫的範圍**不含**通知本體,僅元數據)。 + +### 11.8 安全與 PII + +- `Target` 不直接 persist;存 `TargetHash`(sha256),便於去重、idempotency;明碼僅在 send 當下傳給 provider +- Email/SMS provider API key、Twilio token 等 → `etc/gateway.yaml` 走環境變數 + secret manager +- Webhook 通道強制 HTTPS + HMAC 簽章(`X-CloudEP-Signature`) + +--- + +## 12. 可讀 UID 設計(已決策) + +### 12.1 格式 + +``` +{UIDPrefix}-{Sequence} + +範例:AMEX-10000000、ACME-10000001、ACME-10000002 +``` + +**已決策:帶租戶前綴**(不用純 Body、不用 UUID)。 + +| 部分 | 規則 | 範例 | +|------|------|------| +| `UIDPrefix` | 2~4 位大寫,來自 `tenant.UIDPrefix` 或 slug 縮寫 | `AMEX`、`ACME` | +| `Sequence` | 十進位遞增整數,**起始 `10000000`**(沿用 `InitAutoID` 語意) | `10000000` | +| 分隔符 | 固定 `-` | `AMEX-10000000` | + +- 人類可讀、客服可逐字口述 +- 不含 UUID / base64 亂碼 +- **`UIDPrefix` 全平台唯一**(已決策);客服輸入 UID 即可定位 tenant + member +- 不同租戶不可相同 `UIDPrefix`;同 prefix 內 Sequence 從 `10000000` 起跳 + +### 12.2 產生(Bucket 取號,支援單租戶 50 萬) + +``` +Redis: member:seq:{tenant_id} counter,初始 10000000 +每個 pod 啟動或耗盡時 INCRBY 500 取一個 bucket,在記憶體內遞號 +UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10) +``` + +- **並發保護**:`{ tenant_id, uid }` unique index。`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` / `CreateUnverified` 命中 dup key(E11000)→ fallback `GetByZitadelUserID` 或 `GetByEmail` 取既有 member。 +- **Pod crash 容忍**:bucket 內未用完的號丟失可接受(UID 不要求嚴格連續、不要求嚴格遞增;只要求租戶內唯一)。 +- **UIDPrefix unique index**:`tenants.{ uid_prefix: 1 } unique`;建租戶時若 prefix 已存在 → 409。 + +--- + +## 13. 資料模型與索引 + +### 13.1 Collections | Collection | 模組 | 說明 | |------------|------|------| -| `members` | member | Profile | +| `members` | member | Profile(含業務驗證旗標、TOTP cipher、Origin) | | `identities` | member | zitadel_sub ↔ uid | | `tenants` | member | 租戶 metadata | | `tenant_ldap_configs` | member | LDAP 同步設定(加密) | | `permissions` | permission | 全局 Permission Tree(平台 seed) | -| `roles` | permission | 租戶 Role(`tenant_id` + `name`) | +| `roles` | permission | 租戶 Role(`tenant_id` + immutable `key`) | | `role_permissions` | permission | Role ↔ Permission ID | | `user_roles` | permission | uid ↔ Role(支援多角色) | -| `role_mappings` | permission | 外部 Group ↔ Role.Name | +| `role_mappings` | permission | 外部 Group ↔ RoleID / Role.Key | +| `notifications` | notification | 通知發送紀錄(idempotency / 重試 / audit) | +| `notification_dlq` | notification | 重試 5 次失敗的 dead letter queue | +| `audit_logs` | (獨立 DB)| 跨模組審計日誌(TTL 90d,§20.1) | -### 12.2 主要索引 +### 13.2 主要索引 ```javascript // members @@ -963,11 +2033,11 @@ bucket 內 sequential 編碼 → Body { http_path: 1, http_method: 1 } // sparse // roles -{ tenant_id: 1, name: 1 } // unique +{ tenant_id: 1, key: 1 } // unique { tenant_id: 1, status: 1 } // role_permissions -{ role_id: 1, permission_id: 1 } // unique +{ tenant_id: 1, role_id: 1, permission_id: 1 } // unique // user_roles { tenant_id: 1, uid: 1, role_id: 1 } // unique @@ -975,9 +2045,28 @@ bucket 內 sequential 編碼 → Body // role_mappings { tenant_id: 1, external_source: 1, external_key: 1 } // unique +{ tenant_id: 1, internal_role_id: 1 } + +// notifications +{ tenant_id: 1, kind: 1, idempotency_key: 1 } // unique(同 key 不重發) +{ tenant_id: 1, uid: 1, occurred_at: -1 } +{ status: 1, attempts: 1, occurred_at: 1 } // worker 撈待重試 + +// notification_dlq +{ tenant_id: 1, occurred_at: -1 } + +// audit_logs(獨立 DB / replica set) +{ tenant_id: 1, occurred_at: -1 } +{ tenant_id: 1, "actor.uid": 1, occurred_at: -1 } +{ tenant_id: 1, action: 1, occurred_at: -1 } +{ occurred_at: 1 } // TTL 90d ``` -### 12.3 分片鍵(100 萬+) +> Identity 映射以 `identities` collection 為 source of truth;`members.zitadel_user_id` 若保留,只作反查快取/denormalized 欄位,更新需由同一 transaction 或補償流程維持一致。 + +> **時間欄位**:`CreateAt` / `UpdateAt` 統一為 **epoch milliseconds(UTC)**。對外 SCIM `meta.created` / `meta.lastModified` 由 SCIM mapper 在序列化時轉 RFC3339Nano;前端展示由 client 負責 timezone。 + +### 13.3 分片鍵(100 萬+) ``` Shard Key: { tenant_id: 1, uid: 1 } @@ -987,13 +2076,16 @@ Shard Key: { tenant_id: 1, uid: 1 } --- -## 13. Redis Key 命名 +## 14. Redis Key 命名 ### auth(`internal/model/auth/redis.go`) ``` auth:jwt:bl:{jti} # 單 token 黑名單,TTL = 剩餘壽命 +auth:jwt:pair:{access_jti} # access_jti → refresh_jti(登出時連 refresh 一起拉黑) auth:gen:{tenant_id}:{uid} # 批量失效代號 +auth:exchange:nonce:{id_token_jti} # Token Exchange 防重放,TTL 10min +auth:stepup:used:{jti} # Step-up token 單次性,TTL = step_up_token TTL ``` ### member(`internal/model/member/redis.go`) @@ -1002,20 +2094,35 @@ auth:gen:{tenant_id}:{uid} # 批量失效代號 member:profile:{tenant_id}:{uid} # profile cache,TTL 5~15min member:sub:{tenant_id}:{sub} # zitadel_sub → uid,TTL 1h member:seq:{tenant_id} # UID bucket counter + +otp:challenge:{tenant_id}:{challenge_id} # {purpose, identifier, code_hash, attempts, expire_at},TTL 5min +otp:rate:{tenant_id}:{purpose}:{identifier} # 重發冷卻 60s +otp:daily:{tenant_id}:{purpose}:{identifier} # 單日上限 INCR,TTL 24h + +totp:enroll:{tenant_id}:{uid} # enroll 暫存 secret_cipher,TTL 10min +totp:used:{tenant_id}:{uid}:{timestep} # TOTP code 防重放,TTL 90s +``` + +### notification(`internal/model/notification/redis.go`) + +``` +notif:idem:{tenant_id}:{kind}:{idempotency_key} # idempotency 結果快取,TTL 24h +notif:quota:{tenant_id}:{channel} # 每租戶每通道 quota,INCR + TTL +notif:retry:zset # 異步重試排程(score = next_retry_at_ms) ``` ### permission(`internal/model/permission/redis.go`) ``` -permission:casbin:rules # Casbin policy rules(List of JSON) +permission:casbin:rules:{tenant_id} # Casbin policy rules(List of JSON) permission:tree:open # 可選:open 節點 cache perm:role_perms:{tenant_id}:{role_id} # role → permission names,TTL 30min -perm:user_roles:{tenant_id}:{uid} # uid → role names,TTL 5min +perm:user_roles:{tenant_id}:{uid} # uid → role keys,TTL 5min ``` --- -## 14. 規模與性能(100 萬+ / 單租戶 50 萬) +## 15. 規模與性能(100 萬+ / 單租戶 50 萬) | 項目 | 策略 | |------|------| @@ -1038,7 +2145,7 @@ indexes ≈ 1~2GB --- -## 15. 目錄結構 +## 16. 目錄結構 ``` gateway/ @@ -1064,15 +2171,46 @@ gateway/ │ │ │ ├── client.go │ │ │ └── attrmap.go │ │ ├── casbin/ # Enforcer 初始化 helper -│ │ └── uid/ -│ │ ├── encode.go -│ │ └── generator.go +│ │ ├── uid/ +│ │ │ ├── encode.go +│ │ │ └── generator.go +│ │ ├── totp/ # RFC 6238 演算法、QR 生成 +│ │ │ ├── totp.go +│ │ │ └── backup_code.go +│ │ ├── crypto/ # AES-GCM secret 加解密 + KMS +│ │ │ └── secret.go +│ │ └── notification/ # Provider 實作(純 IO 封裝) +│ │ ├── email/ +│ │ │ ├── sendgrid.go +│ │ │ ├── ses.go +│ │ │ └── smtp.go +│ │ ├── sms/ +│ │ │ ├── twilio.go +│ │ │ ├── sns.go +│ │ │ └── smsapi.go +│ │ └── push/ # 預留 │ │ │ ├── model/ │ │ ├── auth/ │ │ │ └── ... -│ │ ├── member/ +│ │ ├── member/ # 含 verification / step_up / totp usecase │ │ │ └── ... +│ │ ├── notification/ # 統一通知入口 +│ │ │ ├── entity/ +│ │ │ │ └── notification.go +│ │ │ ├── enum/ +│ │ │ │ ├── channel.go +│ │ │ │ ├── kind.go +│ │ │ │ └── status.go +│ │ │ ├── repository/ +│ │ │ │ └── notification.go +│ │ │ ├── usecase/ +│ │ │ │ ├── notifier.go +│ │ │ │ ├── template.go +│ │ │ │ └── worker.go +│ │ │ ├── config/ +│ │ │ ├── errors.go +│ │ │ └── redis.go │ │ └── permission/ │ │ ├── entity/ │ │ │ ├── permission.go @@ -1108,7 +2246,9 @@ gateway/ │ │ │ └── worker/ │ ├── directory_sync/ -│ └── policy_sync/ # 可選:定時 LoadPolicy +│ ├── policy_sync/ # 可選:定時 LoadPolicy +│ ├── notification_retry/ # 異步重試、DLQ 巡檢 +│ └── member_anonymize/ # 軟刪 30 天後匿名化(§5.7) │ ├── etc/ │ ├── gateway.yaml @@ -1121,7 +2261,7 @@ gateway/ --- -## 16. 設定檔 +## 17. 設定檔 `etc/gateway.yaml` 擴充草案: @@ -1139,11 +2279,73 @@ RefreshAuth: AccessExpire: 604800 Zitadel: - Issuer: https://id.example.com + Issuer: https://id.internal.example.com # self-hosted 內網 ClientID: ${ZITADEL_CLIENT_ID} - JWKSUrl: https://id.example.com/oauth/v2/keys - MgmtURL: https://id.example.com/management/v1 + JWKSUrl: https://id.internal.example.com/oauth/v2/keys + MgmtURL: https://id.internal.example.com/management/v1 MgmtToken: ${ZITADEL_MGMT_TOKEN} + EnforceAdminMFA: true # admin 級 role(tenant_owner/tenant_admin/platform_super_admin)強制 TOTP + # Self-hosted:LDAP IdP 由 ZITADEL 直連企業 AD/OpenLDAP + +StepUp: + TokenSecret: ${JWT_STEPUP_SECRET} + TokenTTLSeconds: 300 + AllowedActions: + - change_business_email + - change_business_phone + - delete_member + - tenant_admin_force_status + - revoke_all_sessions + +Verification: + OTPLength: 6 + OTPTTLSeconds: 300 + ResendCooldownSeconds: 60 + DailyLimit: 10 + MaxAttempts: 5 + +TOTP: + Issuer: CloudEP # 顯示在 Authenticator App 上的名稱 + Algorithm: SHA1 # 相容 Google Authenticator + Digits: 6 + PeriodSeconds: 30 + Window: 1 # 容忍 ±1 個 30s 區間 + BackupCodeCount: 10 + BackupCodeLength: 12 # hex chars + SecretKEK: ${TOTP_KEK} # AES-256 KEK;建議走 KMS / Vault + EnrollTTLSeconds: 600 + +Notification: + DefaultLocale: zh-tw + Async: + QueueRedisKey: notif:retry:zset + Worker: 4 # worker goroutine 數 + MaxRetry: 5 + BackoffSeconds: [1, 5, 30, 300, 1800] + RatePerTenant: # 每租戶通道配額(防爆發 / 防濫用) + Email: 10000 # 每天 + SMS: 5000 + Email: + Provider: sendgrid # sendgrid | ses | smtp + APIKey: ${SENDGRID_API_KEY} + From: noreply@example.com + Templates: # 對應 TemplateRegistry key → provider template id + verify_email: d-xxxxxxxxxxxxx + step_up_email: d-yyyyyyyyyyyyy + account_suspended: d-zzzzzzzzzzzzz + tenant_welcome: d-aaaaaaaaaaaaa + SMS: + Provider: twilio # twilio | sns | smsapi + AccountSID: ${TWILIO_ACCOUNT_SID} + AuthToken: ${TWILIO_AUTH_TOKEN} + From: "+1234567890" + Templates: + verify_phone: "Your verification code is {code} (valid {expires_in}s)" + step_up_phone: "Step-up code: {code}" + Push: + Enabled: false # 預留 + Webhook: + HMACSecret: ${NOTIF_WEBHOOK_HMAC} Mongo: # 見 internal/library/mongo 設定 @@ -1159,62 +2361,225 @@ Member: Permission: RBACModelPath: etc/rbac.conf PolicySyncInterval: 5m - PlatformSuperAdminUID: ${PLATFORM_SUPER_ADMIN_UID} # 對應舊 GodDog bypass + PolicyReloadChannel: casbin:reload # Redis Pub/Sub 通道(即時通知,5m cron 兜底) + PlatformAdminTenantID: ${PLATFORM_ADMIN_TENANT_ID} + PlatformAdminRoleKey: platform_super_admin + PlatformAdminAllowlistUIDs: ${PLATFORM_ADMIN_ALLOWLIST_UIDS} # break-glass 用,必須 audit CacheTTLSeconds: 300 + +DirectorySync: + MissingThreshold: 3 + MaxChangeRatio: 0.20 + DryRunOnFirstSync: true + DefaultWindow: 24h + AlertSink: ${OPS_WEBHOOK_URL} + +AuditLog: + Sink: mongo # mongo | otel | dual + Mongo: + DB: gateway_audit # 建議獨立 DB instance / replica set + Collection: audit_logs + BatchSize: 100 + FlushInterval: 1s + TTLDays: 90 + OTEL: + Endpoint: ${OTEL_ENDPOINT} # Sink = otel / dual 時生效 + +RateLimit: + Enabled: true + RedisPrefix: rl + WindowSeconds: 60 + Rules: + - Match: /api/v1/auth/* + ByIP: 60 # 60 req / min / IP + ByUID: 30 # 30 req / min / UID(已登入時) + - Match: /api/v1/auth/step-up/* + ByUID: 10 + - Match: /scim/v2/* + ByToken: 6000 # 6000 req / min / SCIM token(約 100rps) + - Match: /api/v1/* + ByUID: 600 # 一般 API 上限 + ByIP: 1200 ``` --- -## 17. 實施順序 +## 18. 實施順序 | 階段 | 內容 | 產出 | |------|------|------| -| **P0** | 目錄骨架、entity、redis key、config | 可啟動、可連 Mongo/Redis | -| **P1** | UID generator + EnsureMember + token exchange | 可登入取得 JWT + 可讀 UID | +| **P0** | 目錄骨架、entity、redis key、config、**`make seed-platform-admin` CLI**(建首位 platform admin uid + role) | 可啟動、可連 Mongo/Redis,平台 admin 可登入 | +| **P1** | UID generator + ProvisioningUseCase(OIDC/LDAP/SCIM 三變體)+ token exchange | 可登入取得 JWT + 可讀 UID | | **P2** | JWT middleware + jti 黑名單 + auth_gen + logout/refresh | 完整 Token 生命週期 | | **P3** | Permission seed + PermissionTree + Casbin RBAC + Redis Adapter | 可 LoadPolicy / Check | +| **P3.5** | Notification Module(統一入口 + Email/SMS Provider)+ Verification + Step-up MFA + **TOTP** | 業務驗證 + TOTP step-up + 高風險守門 | | **P4** | member profile API + 預設 Role seed + CasbinRBACMiddleware | `/members/me` + API 授權生效 | | **P5** | RolePermission + UserRole + B2B Role CRUD + Permission 勾選 API | 租戶完全自定義 | | **P6** | Tenant 建立 + ZITADEL CreateOrg + LDAP 設定 | 多租戶 | -| **P7** | Directory Sync Worker(AD + OpenLDAP) | 企業目錄同步 | +| **P7** | Directory Sync Worker(AD + OpenLDAP)+ §10.4 guardrail | 企業目錄同步(誤判保護完備) | | **P8** | SCIM 2.0 endpoint + Group 映射 | 企業 provisioning | -| **P9** | 壓測(100 萬 seed)、sharding、調優 | 上線準備 | +| **P8.5** | Audit log sink(Mongo 獨立 collection)+ Rate Limit middleware(見 §20) | 可審計 / 防濫用 | +| **P9** | 壓測(100 萬 seed)、sharding、調優、JWT kid 多版本驗證 | 上線準備 | --- -## 18. 待決策事項 +## 19. 已決策事項 -| # | 議題 | 選項 | 備註 | -|---|------|------|------| -| 1 | UID 格式 | `ACME-ODWXGYBK` vs 純 `ODWXGYBK` | 建議带前缀 | -| 2 | SCIM 路由 | `/scim/v2/tenants/{id}` vs 獨立子域名 | | -| 3 | ZITADEL 部署 | Self-hosted vs Cloud | 影響 LDAP 網路 | -| 4 | 權限變更生效 | 仅清 cache vs 强制 INCR auth_gen | 建议重要變更 INCR | -| 5 | B2C 租戶 | 是否允許自定義 Role | 建议 B2C 只用 seed 模板,B2B 完全自定義 | -| 6 | Refresh Token | 是否輪換 + 舊 jti 黑名單 | 建议輪換 | -| 7 | UserRole | 一 user 多 role vs 單 role | 建议多 role(Casbin 逐一 Check) | -| 8 | PlatformSuperAdminUID | 固定 uid vs 平台 Role | 建议保留 bypass uid + 后续可移除 | +| # | 議題 | **決策** | 設計影響 | +|---|------|----------|----------| +| 1 | UID 格式 | **`{Prefix}-{Sequence}`**,如 `AMEX-10000000` | §12;Sequence 起跳 `10000000` | +| 2 | SCIM 路由 | **`/scim/v2/tenants/{tenant_id}/...`** | §7.5、§10.3 | +| 3 | ZITADEL 部署 | **Self-hosted** | §3.3;LDAP 內網/VPN 連線 | +| 4 | 權限變更生效 | **UserRole 變更 `INCR auth_gen`;RolePermission 變更 reload policy + cache invalidate** | §4.5、§6.11 | +| 5 | B2C 租戶 | **唯讀 seed 模板**,不可自定義 Role | §6.12;B2C 禁用 Role CRUD API | +| 6 | Refresh Token | **輪換 + 舊 refresh jti 黑名單** | §4.5 Refresh 輪換 | +| 7 | Casbin 多租戶隔離 | **policy 帶 `tenant_id` + immutable `role_key`** | §6.7;避免同名 role 跨租戶污染 | +| 8 | SCIM externalId | **保留給客戶端外部識別,不等於 Gateway UID** | §10.3;Gateway UID 作為 SCIM id 或 extension | +| 9 | Platform Admin bypass | **平台 role + allowlist,必須 audit** | §6.7、§8.2;不放在 Casbin matcher | +| 10 | UIDPrefix | **全平台唯一**(`tenants.uid_prefix` unique index) | §12.2 | +| 11 | JWT Claims 內容 | **不放 role / permission 快照**,每次查 cache | §4.3 | +| 12 | Refresh Token Reuse | **舊 refresh 二次使用 = 盜用 → INCR auth_gen + audit** | §4.5 | +| 13 | Token Exchange 防重放 | **id_token nonce SETNX + iat 5 分鐘窗口** | §4.5 | +| 14 | Logout 對應 | **Issue 時 redis 記 access↔refresh jti pair** | §4.5 | +| 15 | RolePermission API 語意 | **PUT 全量取代** `{ permission_names: [...] }` + 強制帶 tenant_id | §6.8、§7.3、§9.3 | +| 16 | 外部來源 UserRole | **按 source 隔離 Replace**,manual 永不被洗 | §6.10 | +| 17 | PlainCode 實作 | **Casbin 額外查 `.plain_code` 變體**,多 role allow 結果取 OR | §6.9 | +| 18 | Permission.Name | **建立後不可改名**;廢棄走 `status=close` + 新建 | §6.4 | +| 19 | 註冊路徑 | **預設**走 ZITADEL Hosted UI(B2C)/ LDAP / SCIM(B2B);**保留** platform-native usecase(`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`)供未來開通 Gateway 原生註冊(含 email OTP 驗證) | §3.4、§5.2.1、§5.9 | +| 20 | 身份 vs 業務驗證分層 | **ZITADEL 管登入身份;Gateway member 自驗業務 email / phone** | §1.2、§5.4 | +| 21 | Step-up MFA | **啟用**;高風險 action 需 5min 單次性 `step_up_token` | §5.6、§9.6 | +| 22 | OTP 投遞通道 | **自送**(透過 Notification Module 包 Email / SMS Provider) | §5.5、§11、§17 | +| 23 | MFA 強制策略 | **平台強制 admin role 走 ZITADEL TOTP**;一般 user 預設不強制,高風險走 Step-up | §3.5 | +| 24 | KYC | **不在初版範圍** | — | +| 25 | 業務 TOTP(Authenticator App) | **啟用**,Gateway 自存 AES-GCM 加密 secret;與 ZITADEL 身份 TOTP 獨立 | §5.8 | +| 26 | Step-up 通道優先序 | **TOTP > SMS > Email**;Start 時依 enrolled 狀態挑通道,可由 client `prefer_channel` 覆寫 | §5.6 | +| 27 | Notification Module | 獨立 model 模組 `internal/model/notification/`,**所有 outbound 通訊**統一走 `NotifierUseCase`;library 層為 provider 純 IO 封裝 | §11 | +| 28 | OTP 入庫策略 | OTP / step-up 等敏感內容 `DoNotPersistBody=true`,notification 紀錄僅留 metadata(target_hash、provider_message_id、status) | §11.3、§11.8 | +| 29 | UseCase 分層 | **Atomic primitives + Composite** 兩層:原子動作(Profile / Lifecycle / Provisioning / OTP / TOTP)可任意組合;Composite(Verification / StepUp)為常用組合預封裝;logic 可選擇路徑 | §5.2 | +| 30 | OTP 設計 | **Purpose-agnostic atomic primitive**:`OTPUseCase.Generate / Verify / Invalidate`;`purpose` 標識用途(registration_email / business_email / step_up / ...),caller 自負投遞與後續副作用 | §5.2.1、§5.2.4、§5.9 | +| 31 | Provisioning 拆分 | `EnsureMember` 拆成 **`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM`** 三個 atomic;不同來源驗證邏輯互不耦合 | §5.2.1 | +| 32 | 平台註冊狀態 | 加回 `unverified` 狀態,**僅** platform-native 路徑會出現;OIDC / LDAP / SCIM 直接 `active` | §5.3 | +| A | SCIM `id` | **SCIM `id` = Gateway UID**(人讀、跨系統一致);`externalId` 留給客戶端;ZITADEL `sub` 放 extension `urn:cloudep:scim:2.0:User:zitadelSub` | §10.3 | +| B | Casbin 多 pod 同步 | **Redis Pub/Sub 即時通知 + 5min cron 全量 reload 兜底**(雙保險,pod 重啟不漏) | §6.11 | +| C | Tenant 建立順序 | **Gateway 先建 tenant 草稿(`status=provisioning`)→ 呼叫 ZITADEL Mgmt 建 Org → 回填 `org_id` → `status=active`;失敗走補償 cron 重試或人工標 failed** | §3.1、§7.4 | +| D | Platform Admin Bootstrap | **`make seed-platform-admin` CLI**(建首個 platform admin uid + role)為主;`PLATFORM_ADMIN_ALLOWLIST_UIDS` 環境變數作 break-glass,**強制 audit** | §18 P0 | +| E | Hybrid 租戶分流 | **雙欄並存**:`Member.Origin`(主來源:zitadel_local / ldap / scim)+ `UserRole.Source`(每個 role 指派來源);sync replace 看 source、唯讀欄位看 origin | §3.2、§5、§6.10 | +| F | SCIM endpoint 授權 | **初版 tenant 級 SCIM Token 全權**(read+write)+ IP allowlist + rate limit + token rotation;v2 再加 `scim.users.write` / `scim.groups.write` scope | §7.5 | +| G | Audit log sink | **獨立 Mongo `audit_logs` collection**(建議獨立 DB instance 或獨立 replica set)+ TTL 90 天 + 異步 batch flush;高風險事件同步寫;可選 OTEL log 雙寫歸檔 | §4.5、§8.2、§20 | +| H | 帳號刪除策略 | **軟刪 30 天後匿名化**:立即 `status=deleted` + 撤銷 token + ZITADEL disable;30 天 cron 匿名化 PII 欄位(email/phone/displayName/avatar/zitadel_sub/business_*);保留 uid/tenant_id/timestamps/audit 連續性 | §5.3、§5.7 | +| I | Member 欄位 SoT | **分欄位策略**:身份欄位(zitadel_sub、IdP email/name、ZITADEL status)→ ZITADEL 為準;業務欄位(business_email/phone、language、currency、avatar)→ Gateway 為準;provisioning 欄位(external_id、ldap_dn)→ 來源系統為準 | §5、§9.1 | +| J | Directory Sync 誤判保護 | **連 3 次(連續 3 天)找不到才 suspend**、單次 sync 異動 > 20% 自動轉 dry-run + 告警、首次部署強制 dry-run、刪除須 cron 通過全部 guardrail | §10.4 | +| K | Rate Limiting | **go-zero middleware + Redis sliding-window 多維**:IP / UID / TenantSCIMToken 三層;`/auth/*` 每 IP 60rpm + 每 UID 30rpm;`/scim/*` 每 token 100rps;一般 API 每 UID 600rpm;OTP 走 §5.5 既有冷卻 | §17 RateLimit、§20 | +| L | JWT Secret Rotation | **支援 `kid` header + 多 key 並存**:Access / Refresh / Step-up 各自獨立 key set;簽發用最新 kid,驗證走 active kid 名單;輪換流程:發新 kid → 新 token 用新 kid → 等舊 token expire → 移除舊 kid | §4.4 | --- -## 附錄 A:ServiceContext 組裝草案 +## 20. Audit Log 與 Rate Limit + +### 20.1 Audit Log + +**Sink(已決策)**:獨立 Mongo `audit_logs` collection(建議**獨立 DB instance** 或 replica set,避免 OLTP 互拖)。 + +```go +type AuditLog struct { + ID primitive.ObjectID + TenantID string + Action string // member.created | role.assigned | step_up.confirmed ... + Actor Actor // {uid, role_keys, ip, ua, jti} + Target Target // {kind: member|role|tenant, id, before, after} + Severity enum.Severity // info | warn | critical + Result enum.Result // success | denied | error + Reason string // 失敗原因 / denied 理由 + Metadata bson.M // 動態欄位,如 step_up_jti、scim_op、source + OccurredAt int64 // epoch ms +} +``` + +**寫入策略:** + +| Severity | 模式 | 失敗處理 | +|----------|------|---------| +| `critical`(停權、刪除、step-up、Platform Admin bypass、權限撤銷) | **同步**寫入;寫失敗則整個業務操作回滾 | 拒絕請求,避免無紀錄通過 | +| `info`(讀取、權限通過) | **異步**:buffered channel → batch insert(`BatchSize=100`、`FlushInterval=1s`) | drop + metrics(告警,但不影響業務) | + +- TTL index:`{ OccurredAt: 1 }` TTL 90 天;超過則歸檔(可選 OTEL log 雙寫保留更久) +- Index:`{ TenantID: 1, OccurredAt: -1 }`、`{ TenantID: 1, Actor.uid: 1, OccurredAt: -1 }`、`{ TenantID: 1, Action: 1, OccurredAt: -1 }` +- **匿名化不影響 audit**:actor / target uid 仍保留(即使 member 已匿名化),達成「最少必要 PII + 連續性」 + +### 20.2 Rate Limit + +**技術選型(已決策)**:go-zero middleware(自製 / 衍生)+ Redis sliding-window。 + +``` +Key: rl:{dimension}:{key}:{path_pattern} # dimension = ip | uid | scim_token +Value: ZSET(timestamp_ms : nonce)TTL = WindowSeconds +``` + +**演算法**: + +``` +1. now := time.Now().UnixMilli() +2. ZREMRANGEBYSCORE rl:... 0 (now - window_ms) +3. count := ZCARD rl:... +4. if count >= limit → 429 + Retry-After +5. ZADD rl:... now {random} +6. EXPIRE rl:... window +``` + +**分層命中規則(順序匹配):** + +| 路徑 | 維度 | 上限 | +|------|------|------| +| `/api/v1/auth/step-up/*` | UID | 10 req/min | +| `/api/v1/auth/*` | IP / UID | 60 / 30 req/min | +| `/scim/v2/*` | SCIM token | 6000 req/min(約 100rps) | +| `/api/v1/*`(其餘) | UID / IP | 600 / 1200 req/min | + +- **公開 endpoint**(exchange / refresh)以 IP 為主、UID 為輔(未登入時無 UID) +- 命中後回 `429` + `Retry-After: {seconds}` + `X-RateLimit-Remaining` +- OTP / 業務驗證走 §5.5 內 `verify:rate` / `verify:daily`,**不重複**經 RateLimit middleware(避免冷卻被消耗) +- 設定見 §17 `RateLimit` + +--- + +## 附錄 A:與 model.md 的關係 + +- 本文件:**做什麼**(架構、流程、API、權限模型) +- [model.md](./model.md):**怎麼寫**(entity / repository / usecase 程式碼規範) + +實作時兩份文件搭配使用。 + +--- + +## 附錄 B:ServiceContext 組裝草案 ```go type ServiceContext struct { Config config.Config Validator validate.Validate - // library clients - Zitadel *zitadel.Client + // library clients(純 IO,純粹封裝外部 SDK) + Zitadel *zitadel.Client + EmailSender libemail.Sender + SMSSender libsms.Sender + SecretCipher libcrypto.Cipher // TOTP secret 加解密 + TOTPGen libtotp.Generator // usecases AuthUC authusecase.TokenUseCase + StepUpTokenUC authusecase.StepUpTokenUseCase MemberProvUC memberusecase.ProvisioningUseCase MemberProfileUC memberusecase.ProfileUseCase MemberAdminUC memberusecase.AdminUseCase + VerificationUC memberusecase.VerificationUseCase + StepUpUC memberusecase.StepUpUseCase + TOTPUC memberusecase.TOTPUseCase TenantUC memberusecase.TenantUseCase ScimUC memberusecase.ScimUseCase + // notification module + NotifierUC notifusecase.NotifierUseCase + // permission usecases(對齊 permission-server 拆分) PermRBACUC permusecase.RBACUseCase PermUC permusecase.PermissionUseCase @@ -1230,33 +2595,33 @@ type ServiceContext struct { ## 附錄 C:permission-server 遷移對照(程式碼級) + + | permission-server 檔案 | Gateway 目標 | 遷移方式 | |------------------------|--------------|----------| | `pkg/usecase/permission_tree.go` | `model/permission/usecase/permission_tree.go` | 幾乎原樣搬移 | -| `pkg/usecase/casbin_redis_rbac.go` | `model/permission/usecase/rbac.go` | 加 `tenant_id` 過濾 | -| `pkg/repository/casbin_redis_adapter.go` | `model/permission/repository/casbin_redis_adapter.go` | 原樣搬移 | +| `pkg/usecase/casbin_redis_rbac.go` | `model/permission/usecase/rbac.go` | 加 `tenant_id` + `role_key` 維度 | +| `pkg/repository/casbin_redis_adapter.go` | `model/permission/repository/casbin_redis_adapter.go` | 改為 tenant-scoped policy key | | `pkg/domain/rbac/rule.go` | `model/permission/rbac/rule.go` | 原樣搬移 | -| `etc/rbac.conf` | `etc/rbac.conf` | 原樣搬移 | +| `etc/rbac.conf` | `etc/rbac.conf` | 加入 tenant request / policy 維度 | | `pkg/usecase/role.go` | `model/permission/usecase/role.go` | `ClientID`→`TenantID` | -| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | 原樣搬移 | +| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | 加 `tenant_id` 防呆與查詢維度 | | `pkg/usecase/user_role.go` | `model/permission/usecase/user_role.go` | 改支援多角色 | | `pkg/usecase/token.go` | **`model/auth/usecase/token.go`** | 不在 permission 模組 | | `generate/database/seeders/*_permission*` | `generate/database/seeders/` 或 Mongo seed | 改為 Gateway seed job | --- -## 附錄 B:與 model.md 的關係 - -- 本文件:**做什麼**(架構、流程、API、權限模型) -- [model.md](./model.md):**怎麼寫**(entity / repository / usecase 程式碼規範) - -實作時兩份文件搭配使用。 - ---- - ## 修訂紀錄 | 日期 | 版本 | 說明 | |------|------|------| | 2026-05-19 | 0.1.0 | 初稿:auth + member + permission(B2B 自定義)+ ZITADEL/LDAP/SCIM | | 2026-05-19 | 0.2.0 | 對齊 app-cloudep-permission-server:Casbin RBAC、Permission Tree、Role/RolePermission | +| 2026-05-19 | 0.3.0 | 已定案 §19(1–6):UID 前綴格式、SCIM tenant_id 路由、ZITADEL self-hosted、auth_gen 強制刷新、B2C 唯讀、Refresh 輪換 | +| 2026-05-19 | 0.4.0 | 補強多租戶 Casbin、immutable Role.Key、SCIM externalId、Platform Admin bypass 與權限生效策略 | +| 2026-05-20 | 0.5.0 | Best-practice 收斂:JWT 不放 role 快照、Refresh Reuse Detection、Token Exchange Nonce、Logout pair、RolePermission tenant 防呆 + PUT 全量取代、外部來源 source 隔離、PlainCode 聚合、Permission.Name 不可改、UIDPrefix 全平台唯一、Role.Key 規則、附錄重排為 A→B→C | +| 2026-05-20 | 0.6.0 | 補入業務驗證分層:Gateway 不提供註冊 API(§3.4);新增業務 Email / Phone 自驗(§5.4、§9.5);Step-up MFA 啟用(§5.6、§9.6);OTP 自送 Email + SMS Provider(§5.5、§17 Notification);平台 admin 強制 ZITADEL TOTP(§3.5);新增對應 Redis key、API、設定、決策列 19–24 | +| 2026-05-20 | 0.7.0 | 待決策 A–L 全數拍板:SCIM id = Gateway UID + ZITADEL sub extension(§10.3);Casbin 多 pod Pub/Sub + 5min cron 兜底(§6.11);Tenant 建立 saga(§3.1);Platform Admin seed CLI(§18 P0);Member.Origin + UserRole.Source 雙欄(§5.4、§6.10);SCIM token 全權 + IP allowlist(§7.5);獨立 audit_logs collection + TTL 90d(§20.1);軟刪 30 天匿名化(§5.7);分欄位 SoT(§5.3);Directory Sync guardrail(§10.4);Redis sliding-window rate limit(§20.2);JWT kid 多 key 並存(§4.4) | +| 2026-05-20 | 0.8.0 | 抽出獨立 **Notification Module**(§11):所有 outbound 通訊統一入口、含 idempotency / 重試 / DLQ / 模板 / 多語、敏感內容 `DoNotPersistBody`;新增 **業務 TOTP**(§5.8)支援 Google Authenticator,與 ZITADEL 身份 TOTP 獨立;step-up 通道優先序改為 **TOTP > SMS > Email**(§5.6);目錄、ServiceContext、Mongo collections、Redis key、設定檔、實施順序、決策列 25–28 同步更新;§11–§19 章節編號全部 +1 | +| 2026-05-20 | 0.9.0 | **UseCase 介面契約凍結(業務邏輯暫不實作)**:§5.2 重寫為 Atomic primitives + Composite 兩層;新增 `OTPUseCase`(purpose-agnostic atomic)、`LifecycleUseCase`(CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending);`ProvisioningUseCase` 拆 `EnsureFromOIDC / LDAP / SCIM` 三變體;`ProfileUseCase` 加 `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomic;加回 `unverified` 狀態(僅 platform-native 路徑);補完 Member entity 欄位、Enum 草案、Request DTO;新增 §5.9 編排示例(5 case);§14 OTP Redis key 改 purpose-based;決策列 19 修正、新增 29–32 |