# Identity / Member / Permission 模組設計草稿 > **狀態**:Draft(待 Review) > **適用專案**:Portal API Gateway(PGW) > **參考實作**:[app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)(Casbin RBAC、Permission Tree、Role/RolePermission) > **最後更新**:2026-05-19 > **前提**:全新 Gateway module,不考慮舊版 member-server 遷移。 本文件描述 Gateway 內 **auth**、**member**、**permission** 三個業務模組的目標架構,整合 **ZITADEL**(身份)、**LDAP**(企業目錄)、**SCIM 2.0**(企業 provisioning),支援 **多租戶** 與 **百萬級會員**(含單租戶 50 萬)。 模組分層與程式碼撰寫規範見 [model.md](./model.md)。 --- ## 目錄 1. [設計目標與原則](#1-設計目標與原則) 2. [模組全景](#2-模組全景) 3. [外部系統分工](#3-外部系統分工) 4. [auth 模組](#4-auth-模組) 5. [member 模組](#5-member-模組) 6. [permission 模組(B2B 自定義)](#6-permission-模組b2b-自定義) 7. [API 規劃](#7-api-規劃) 8. [Middleware 鏈](#8-middleware-鏈) 9. [核心流程](#9-核心流程) 10. [LDAP 與 SCIM](#10-ldap-與-scim) 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) --- ## 1. 設計目標與原則 ### 1.1 目標 | 目標 | 說明 | |------|------| | 統一身份 | ZITADEL 作為 IdP(含 LDAP IdP、Social Login) | | 業務會員 | Gateway `member` 模組管理 tenant-scoped profile | | 細粒度授權 | Gateway `permission` 模組(**Casbin RBAC + Permission Tree**);**每個 B2B 租戶可自定義 Role 並勾選 Permission** | | Token | go-zero JWT 驗證 + Redis 黑名單(只黑名單 JWT) | | 企業整合 | SCIM 2.0 + LDAP Directory Sync(AD + OpenLDAP) | | 規模 | 全平台 100 萬+ 會員;單租戶可達 50 萬 | | UID | 人類可讀、帶租戶前綴,如 `AMEX-10000000`;唯一性以 `tenant_id + uid` 為準 | ### 1.2 核心原則 1. **職責分離** - `auth`:你是誰(Authentication) - `member`:你的業務資料是什麼(Profile) - `permission`:你能做什麼(Authorization) 2. **LDAP 不做登入 bind** - 登入驗證由 ZITADEL LDAP IdP 處理 - Gateway 的 LDAP client 僅供 Directory Sync(read-only) 3. **Token Exchange** - 對外 API 只接受 Gateway 簽發的 CloudEP JWT - ZITADEL OIDC token 僅在 `/auth/token/exchange` 使用一次 4. **租戶隔離** - 所有持久化資料以 `tenant_id` 為邊界 - JWT `tenant_id` 與請求資源必須一致 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** 比對 `(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 --- ## 2. 模組全景 ``` ┌─────────────────────────────────────────────────────────────────┐ │ Portal API Gateway (go-zero) │ ├─────────────────────────────────────────────────────────────────┤ │ generate/api/ │ │ auth.api · member.api · permission.api · tenant.api · scim.api│ ├─────────────────────────────────────────────────────────────────┤ │ internal/middleware/ │ │ jwt_revoke · casbin_rbac · scim_auth · tenant_context │ ├─────────────────────────────────────────────────────────────────┤ │ internal/model/ │ │ 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/ │ │ notification/email · notification/sms · notification/push │ ├─────────────────────────────────────────────────────────────────┤ │ internal/worker/ │ │ 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|notification}/usecase(interface) ↓ repository → MongoDB / Redis logic 不 import entity / repository(見 model.md) 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 | 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 多租戶對應 ``` 1 CloudEP Tenant = 1 ZITADEL Organization = 1 資料隔離邊界 ``` | 欄位 | 來源 | 用途 | |------|------|------| | `tenant_id` | ZITADEL `org_id` | 分片鍵、授權邊界 | | `identity_id` | ZITADEL `sub` | 身份映射 | | `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 租戶類型 | 類型 | 登入 | LDAP | 權限 | |------|------|------|------| | **B2C** | Email / Social | 無 | 系統預設 Role(不可或不常自定義) | | **B2B** | ZITADEL → LDAP IdP | 有 | **完全自定義 Role + Permission** | | **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 統一註冊 BFF) > **完整規格**:[auth-unified-registration.md](./auth-unified-registration.md)(2026-05-21 起為準;本節為摘要) Gateway **暴露** `/api/v1/auth/register*` 作為 B2C 統一註冊入口;ZITADEL 作為 identity 後端(帳密、OIDC),**不再**要求使用者跳轉 ZITADEL Hosted Register UI。 | 租戶類型 | 註冊路徑 | 說明 | |---------|----------|------| | **B2C Email** | `POST /auth/register` → OTP → `POST /auth/register/confirm` | Logic 編排:invite consume → `zitadel.CreateHumanUser` → `Lifecycle.CreateUnverified` → registration OTP → `Activate` → CloudEP JWT | | **B2C Social(Google)** | `POST /auth/register/social/start` → OAuth → `GET /auth/register/social/callback` | OAuth **前**綁定 invite session(Redis);callback 消耗 invite → `EnsureFromOIDC` → registration metadata → JWT | | **B2B(LDAP)** | 由 IT 在 AD / OpenLDAP 建帳;Directory Sync 預 provision | 登入走 LDAP IdP → `EnsureFromLDAP` JIT;**不經** register API | | **B2B(SCIM)** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway;**不經** register API | **商務規則(Logic 層,非 usecase):** - Invite code **必填**(`Member.Registration.RequireInviteCode`,預設 `true`) - 條款版本 `accept_terms_version` 必填 - 註冊完成前 **不發** CloudEP JWT;confirm / social callback 後才 `IssuePair` - Invite 消耗後若 ZITADEL / member 失敗 → **不回滾 invite**(防刷;見 auth-unified-registration §9) - Social 登入(非註冊)走 **`/auth/login/social/*`**,與 register session **分 state 前綴**(`login:` vs `reg:`) **登入(非註冊)** 見 [auth-unified-registration.md §3.3](./auth-unified-registration.md#33-登入非註冊):`/auth/login`、`/auth/token/refresh`、`/auth/token/exchange`、Social login。 > ZITADEL 內建 email 驗證用於 **身份** 登入門檻;平台原生註冊另以 Gateway registration OTP(`OTPPurposeRegistrationEmail`)確認後才 `Activate`。 ### 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 **互不取代** --- ## 4. auth 模組 路徑:`internal/model/auth/` ### 4.1 職責 - 驗證 ZITADEL OIDC token(id_token / authorization_code + PKCE) - 編排 `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` 與 `permission.SyncRolesFromClaims` - 簽發 CloudEP JWT(access + refresh) - **簽發 Step-up Token**(高風險操作用,短壽命 5min;見 §5.6) - 登出:jti 黑名單 - 批量失效:`auth_gen`(停權 / 改密碼 / 權限強制刷新) ### 4.2 UseCase 介面 ```go type TokenUseCase interface { Exchange(ctx context.Context, req *ExchangeRequest) (*TokenPair, error) Refresh(ctx context.Context, req *RefreshRequest) (*TokenPair, error) 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 ```go type Claims struct { jwt.RegisteredClaims // 含 jti, exp, iat TenantID string `json:"tenant_id"` UID string `json:"uid"` 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 } ``` > **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: AccessExpire: 900 # 15 分鐘 ActiveKID: v2 # 當前簽發用 kid Keys: # 驗證可接受的 kid 名單(含正在退役的) - kid: v1 Secret: ${JWT_ACCESS_SECRET_V1} - kid: v2 Secret: ${JWT_ACCESS_SECRET_V2} RefreshAuth: 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: 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 撤銷(登出) ``` Key: auth:jwt:bl:{jti} Value: 1 TTL: token 剩餘有效時間(exp - now) ``` ``` 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} ``` #### 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} 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} - redis key 不存在 → 視為 0 - 簽發 token 時 claims.auth_gen = redis.GET 或 0 5. 注入 context:tenant_id, uid(role keys 由下一層 CasbinRBACMiddleware 從 cache 載入) ``` --- ## 5. member 模組 路徑:`internal/model/member/` ### 5.1 職責 - 會員 Profile CRUD(tenant-scoped) - Identity 映射(`zitadel_sub` ? `uid`) - Tenant metadata 與 LDAP 同步設定 - UID 產生(可讀格式) - SCIM 業務寫入(SCIM `id` / Gateway UID + 客戶端 `externalId`) - Directory Sync Worker(AD + OpenLDAP) - 會員狀態(active / suspended / deleted)→ 通知 auth 撤銷 token - **業務級驗證**:business email / phone 綁定 + OTP 自送 - **Step-up MFA OTP 驗證**(搭配 auth 模組簽 step_up_token) ### 5.2 UseCase 介面 > **設計原則(呼應 model.md)**:每個 UseCase 是**原子業務操作**,**不假設前後步驟存在**。流程編排(如「註冊 → 寄驗證信 → 啟用」)由 **logic 層**用多個 UseCase 拼裝;本層只負責單一動作 + 副作用。 > > 介面分兩層: > 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification)。Logic 可任意組合,跨流程共用。 > 2. ~~**Composite**~~:原本設想的「把幾個 atomic 預先組好的快捷組合」**已廢棄**。 > - 與 [model.md §6.1](./model.md) 抵觸:usecase 之間禁止互呼叫。 > - 目前實作只提供 atomic(`OTPUseCase`、`TOTPUseCase`、`ProfileUseCase` 等),多步驟流程(如 verify-email = `OTP.Generate` → `Notifier.Send` → `Profile.SetBusinessEmailVerified`)一律在 **logic 層**編排,logic handler 自己持有多個 usecase interface。 > - 下方第 5.2.2 節保留 `VerificationUseCase` 介面定義僅為「**邏輯流的描述參考**」,不會在 `domain/usecase/` 出現。 > > 業務邏輯(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 } // ────────────────────────────────────────────────────────── // 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 PatchGroup(ctx context.Context, req *ScimPatchGroupRequest) error } type DirectorySyncUseCase interface { SyncTenant(ctx context.Context, tenantID string) (*SyncResult, error) } ``` #### 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` | **僅平台原生註冊**會出現:member 已建立,但註冊 email 尚未通過 OTP 驗證 | 不簽 token、不可登入;逾期由 cron `AbortPending` 清理 | | `active` | 正常使用 | — | | `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 編排示例 > 展示 atomic primitives 在 **logic 層** 的組合方式。B2C 註冊 / 登入 **已實作** 於 `internal/logic/auth/`;見 [auth-unified-registration.md](./auth-unified-registration.md)。 #### Case A:平台原生註冊 + Email OTP 驗證(**已實作**:`RegisterLogic` / `RegisterConfirmLogic`) ```go // HTTP: POST /auth/register → Logic 編排(摘要) // 1) invite consume(若 RequireInviteCode) // 2) zitadel.CreateHumanUser m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{ TenantID: tenantID, Email: email, DisplayName: name, ZitadelUserID: zitadelSub, }) // 3) registration metadata.Record(channel=email) chal, plain, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ TenantID: tenantID, UID: m.UID, Purpose: OTPPurposeRegistrationEmail, Target: email, }) notifier.Send(ctx, &SendRequest{ Kind: NotifyVerifyRegistrationEmail, Data: map[string]any{"code": plain, ...} }) // HTTP: POST /auth/register/confirm _ = mOTP.Verify(ctx, &VerifyOTPRequest{ ... Purpose: OTPPurposeRegistrationEmail }) _ = mLifecycle.Activate(ctx, tenantID, m.UID) // auth.IssuePair → { access_token, refresh_token } ``` #### 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 自行決定組合與順序。 --- ## 6. permission 模組(B2B 自定義,參考 permission-server) 路徑:`internal/model/permission/` > 本節吸收 [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server) 已驗證的設計:**Casbin + Redis RBAC**、**Permission Tree(父子繼承)**、**HTTP Path/Method 綁定**。 > **與舊 permission-server 的差異**:Token 簽發/驗證/黑名單移至 Gateway `auth` 模組;`ClientID` 改為 `tenant_id`;支援多租戶 B2B 各自定義 Role。 ### 6.1 設計目標 | 能力 | 說明 | |------|------| | **Permission Tree** | 全局權限樹(平台 seed),父子節點繼承;父節點關閉則子節點不可用 | | **Casbin RBAC** | 以 `(tenant_id, role_key, http_path, http_method)` 做 API 授權;path 支援 `keyMatch2` 萬用字元 | | **B2B 自定義 Role** | 每個租戶建立自訂 Role,從全局 Catalog **勾選** Permission(不可自創 Permission 字串) | | **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.Key | | **細粒度擴展** | 同一 API 可掛 `.plain_code` 子權限(如明碼查詢),沿用舊設計 | ### 6.2 與 app-cloudep-permission-server 對照 | permission-server | Gateway permission 模組 | 備註 | |-------------------|---------------------------|------| | `TokenService` | **`auth` 模組** | JWT 不再放 permission-server | | `PermissionService`(空) | 完整 HTTP API | 直接在 Gateway 暴露 | | `entity.Permission` | 沿用 + `tenant_id` 不適用(全局 Catalog) | Permission 為平台級 | | `entity.Role.ClientID` | `Role.TenantID` | 租戶隔離 | | `entity.Role.UID` | `Role.CreatorUID` | 建立者,可選 | | `entity.Role.Name` | `Role.DisplayName` | 顯示名稱,可改名 | | — | `Role.Key` | **Casbin policy 的 role 欄位**,租戶內唯一且不可改 | | `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | 沿用 | | `PermissionTree` | `usecase/permission_tree.go` | 沿用 | | `AdminRoleUID` / `GodDog` | `PlatformAdminRoleKey` + allowlist | 平台超級管理員 bypass,需 audit | | `permission.Type` | `enum.PermissionType` | `BackendUser` / `FrontendUser` | ### 6.3 核心概念 ``` Permission(全局樹) 平台定義,含 name / http_path / http_method / parent / status / type Role(租戶自定義) 租戶建立的角色;display_name 可改,key 不可改,如 sales_supervisor、tenant_admin RolePermission Role ? Permission ID 多對多;勾選時自動補父節點 UserRole uid ? Role;一 user 可多 role RoleMapping 外部 Group/Role → 內部 RoleID / Role.Key Casbin Policy p, {tenant_id}, {role_key}, {http_path}, {http_method}, {permission.Name} ``` ### 6.4 Permission Entity(全局 Catalog) 沿用 permission-server 的 `entity.Permission` 結構,MongoDB collection:`permission`。 ```go type Permission struct { 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 一致) ``` {domain}.{module}.{action} {domain}.{module}.{action}.{variant} # 如 .plain_code ``` #### Permission Tree 範例(seed 草案) ``` member.info.management # 一級:會員資訊管理(分類,無 HTTP) ├── member.basic.info # 二級:基礎資訊 │ ├── member.info.select # GET /api/v1/members/me │ ├── member.info.update # PATCH /api/v1/members/me │ └── member.info.select.plain_code # GET /api/v1/members(明碼欄位) ├── member.admin.list # GET /api/v1/members ├── member.admin.read # GET /api/v1/members/:uid ├── member.admin.update # PATCH /api/v1/members/:uid └── member.admin.status # PATCH /api/v1/members/:uid/status permission.role.management # 一級:角色權限管理 ├── permission.role.read # GET /api/v1/permissions/roles ├── permission.role.write # POST/PUT/DELETE roles ├── permission.assign.write # POST/DELETE user roles └── permission.catalog.read # GET /api/v1/permissions/catalog tenant.management ├── tenant.read ├── tenant.ldap.write └── tenant.sync.trigger scim.management ├── scim.users.write └── scim.groups.write system.management # 平台級 └── system.tenant.create ``` > **分類節點**(無 `http_path`)供 UI 樹狀勾選;**葉節點**才寫入 Casbin Policy。 > 新增 Permission 走平台 seed migration;租戶**不可**自行新增 Permission 名稱。 #### Permission Tree 行為(沿用 permission-server) 1. **`filterOpenNodes`**:父節點 `status=close` → 整棵子樹不可用 2. **`getFullParentPermissionIDs`**:勾選子權限 → 自動加入所有父節點 ID 3. **`getFullParentPermission`**:查 Role 權限 → 回傳含父節點的完整 permission name → status map(供前端 UI) ### 6.5 Role Entity(B2B 租戶自定義) ```go type Role struct { 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, 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`) 2. 系統 seed 的預設 Role(`is_system=true`)可修改 Permission 集合,**tenant_owner 不可刪** 3. Role 綁定的 Permission 必須是全局 Catalog 中 `status=open` 的節點 4. 租戶**不可**勾選 `system.*` 權限(除非平台另行開啟) 5. 至少保留一個 Role 含 `permission.role.write`,避免租戶自鎖 #### 預設 Role 模板(建立 B2B tenant 時 seed) | Key | DisplayName | 預設勾選(Permission Name) | |------|------|----------------------------| | `tenant_owner` | 租戶擁有者 | 除 `system.*` 外全部 open 節點 | | `tenant_admin` | 租戶管理員 | member.*, permission.*, tenant.*, scim.* | | `member_manager` | 會員管理 | member.admin.list, member.admin.read, member.admin.status | | `member` | 一般會員 | member.info.select, member.info.update | | `viewer` | 唯讀 | member.info.select | B2B 管理員範例: ``` 建立 Role:sales_supervisor 勾選:member.admin.list, member.admin.read 指派:POST /permissions/users/{uid}/roles { "role_id": "..." } → RolePermission.Create → getFullParentPermissionIDs 自動補 parent → LoadPolicy 刷新 Casbin ``` ### 6.6 UserRole / RolePermission ```go type UserRole struct { TenantID string UID string RoleID string // Role._id hex Source enum.RoleSource // manual | zitadel | ldap | scim CreateAt int64 UpdateAt int64 } // Index: { tenant_id, uid, role_id } unique // Index: { tenant_id, uid } type RolePermission struct { TenantID string RoleID string PermissionID string CreateAt int64 UpdateAt int64 } // Index: { tenant_id, role_id, permission_id } unique ``` > 舊 permission-server 的 UserRole 為一 user 一 role(Update 覆蓋);新設計**支援多角色**,Middleware 對每個 immutable role key 做 Casbin enforce,任一 allow 即通過。 ### 6.7 Casbin RBAC(核心授權引擎) #### 模型檔 `etc/rbac.conf`(Gateway 多租戶版) ```ini [request_definition] r = tenant, role, path, method [policy_definition] p = tenant, role, path, methods, name [policy_effect] e = some(where (p.eft == allow)) [matchers] 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**:不放在 Casbin matcher;由 Middleware 先驗證 platform role / allowlist 後短路,並寫入 audit log #### Policy 載入(`RBACUseCase.LoadPolicy`) ``` 1. permissionRepo.GetAll → GeneratePermissionTree → filterOpenNodes 2. roleRepo.All(tenant_id) → 每個 role 取 rolePermissionRepo.Get 3. 對每個 (role, permission) 若 http_path + http_method 非空: 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 // 輸入:tenantID, roleKey, requestPath, requestMethod ok, policy, err := enforcer.EnforceEx(tenantID, roleKey, path, method) // 回傳 CheckRolePermissionStatus: // Allow: bool // PermissionName: string // 命中的 permission.Name // PlainCode: bool // 是否有 .plain_code 子權限(GET 時額外查) ``` #### Policy 同步策略 | 觸發 | 動作 | |------|------| | RolePermission 變更 | 該 tenant `LoadPolicy` + 權限快取失效 | | Permission status 變更(平台) | 全局 `LoadAllPolicies` + 權限快取失效 | | 定時 cron(如 5min) | `SyncPolicy` 兜底 | | Gateway 啟動 | 初始 `LoadPolicy` | Redis 儲存 Casbin rules:`permission:casbin:rules:{tenant_id}`(List of JSON `rbac.Rule`)。全量載入時可掃描 tenant-scoped keys,或由 repository 依 MongoDB role/permission 重建。 ### 6.8 UseCase 介面 ```go // --- Casbin 授權(核心)--- type RBACUseCase interface { Check(ctx context.Context, req *CheckRequest) (*CheckResult, error) LoadPolicy(ctx context.Context, tenantID string) error LoadAllPolicies(ctx context.Context) error SyncPolicy(ctx context.Context, interval time.Duration) } type CheckRequest struct { TenantID string UID string RoleKey string // immutable Role.Key Path string // 實際請求 path Method string // 實際 HTTP method } type CheckResult struct { Allow bool PermissionName string PlainCode bool MatchedRole string } // --- Permission Catalog(平台級)--- type PermissionUseCase interface { All(ctx context.Context, status *enum.Status) ([]PermissionDTO, error) FilterAll(ctx context.Context) ([]PermissionDTO, error) // 樹狀過濾 open 節點 Insert(ctx context.Context, req *CreatePermissionRequest) error // 平台 Admin Update(ctx context.Context, id string, req *UpdatePermissionRequest) error } // --- Role(租戶級,B2B 自定義)--- type RoleUseCase interface { List(ctx context.Context, req *ListRolesRequest) ([]RoleDTO, int64, error) All(ctx context.Context, tenantID string) ([]RoleDTO, error) GetByID(ctx context.Context, tenantID, id string) (*RoleDTO, error) Create(ctx context.Context, req *CreateRoleRequest) error Update(ctx context.Context, id string, req *UpdateRoleRequest) error Delete(ctx context.Context, tenantID, id string) error } // --- RolePermission --- type RolePermissionUseCase interface { 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) 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, source enum.RoleSource) error // 全量取代「該 source」的指派 } // --- 外部映射 --- type RoleMappingUseCase interface { List(ctx context.Context, tenantID string) ([]RoleMappingDTO, error) Upsert(ctx context.Context, req *UpsertRoleMappingRequest) error Delete(ctx context.Context, tenantID, id string) error SyncFromZitadelClaims(ctx context.Context, req *SyncFromZitadelRequest) error SyncFromScimGroup(ctx context.Context, req *SyncFromScimGroupRequest) error SyncFromLDAPGroups(ctx context.Context, req *SyncFromLDAPGroupsRequest) error } // --- 聚合查詢(前端菜?)--- type AuthorizationQueryUseCase interface { GetMyPermissions(ctx context.Context, tenantID, uid string) (enum.Permissions, error) GetMyRoles(ctx context.Context, tenantID, uid string) ([]string, error) } ``` > **跨租戶防呆**:所有 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(JwtRevokeMiddleware 已驗過 JWT + auth_gen) 1. 取 ctx.tenant_id, ctx.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 ``` > **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 映射 ```go type RoleMapping struct { TenantID string ExternalSource enum.RoleSource // zitadel | ldap | scim ExternalKey string // ZITADEL role / LDAP group DN / SCIM group id InternalRoleID string // 租戶 Role._id hex InternalRoleKey string // denormalized Role.Key,方便查詢與審計 } // Index: { tenant_id, external_source, external_key } unique ``` | 來源 | ExternalKey 範例 | 映射到 | |------|------------------|--------| | ZITADEL | `org_admin` | `tenant_admin` | | 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)` + `perm:role_perms:*` 快取失效 | | Role Create/Update/Delete | `LoadPolicy(tenant_id)` | | 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` | #### 多 Pod 同步機制(已決策) ``` 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 結果)。 --- ## 7. API 規劃 檔案:`generate/api/` ### 7.1 auth.api(公開 / 需 JWT 視 API 而定) > **已實作**(2026-05-21):下表「狀態」欄?注;完整請求/回應見 [auth-unified-registration.md §4](./auth-unified-registration.md#4-api-規格generateapiauthapi) 與 `generate/api/auth.api`。 | Method | Path | 說明 | 鑑權 | 狀態 | |--------|------|------|------|------| | POST | `/api/v1/auth/register` | Email + 密碼註冊(ZITADEL + member + registration OTP) | 公開 | ? | | POST | `/api/v1/auth/register/confirm` | 確認 registration OTP → CloudEP JWT | 公開 | ? | | POST | `/api/v1/auth/register/resend` | 重寄 registration OTP | 公開 | ? | | POST | `/api/v1/auth/register/social/start` | Social **註冊** start(含 invite session) | 公開 | ? | | GET | `/api/v1/auth/register/social/callback` | Social **註冊** OAuth callback → JWT | 公開 | ? | | POST | `/api/v1/auth/login` | Email + 密碼登入(ZITADEL ROPG → JWT) | 公開 | ? | | POST | `/api/v1/auth/login/social/start` | Social **登入** start(無 invite) | 公開 | ? | | GET | `/api/v1/auth/login/social/callback` | Social **登入** OAuth callback → JWT | 公開 | ? | | POST | `/api/v1/auth/token/refresh` | 刷新 JWT | 公開(帶 refresh) | ? | | POST | `/api/v1/auth/token/exchange` | ZITADEL `id_token` → CloudEP JWT(企業 SSO) | 公開 | ? | | 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(示例) | 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 字串。 > 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/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 勾選 `{ "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 映射列表 | | PUT | `/api/v1/permissions/role-mappings` | 新增/更新映射 | | POST | `/api/v1/permissions/policy/reload` | 手動觸發 LoadPolicy(平台 Admin) | ### 7.4 tenant.api(平台 / 租戶 Admin) | Method | Path | Casbin 命中 Permission(示例) | |--------|------|-------------------------------| | POST | `/api/v1/admin/tenants` | `system.tenant.create` | | GET | `/api/v1/admin/tenants/:tenant_id` | `tenant.read` | | PUT | `/api/v1/admin/tenants/:tenant_id/ldap` | `tenant.ldap.write` | | POST | `/api/v1/admin/tenants/:tenant_id/directory-sync` | `tenant.sync.trigger` | ### 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 設定) - `{tenant_id}` = ZITADEL `org_id`,與 JWT `tenant_id` 一致 - SCIM 請求不走 CloudEP JWT;授權由 tenant 級 SCIM token + 可選 Casbin 細分 --- ## 8. Middleware 鏈 ### 8.1 一般受保護 API **目前已實作(member 模組):** ``` Request → CloudEPJWT middleware(可選 Bearer access JWT → 注入 tenant_id + uid 至 context) → member handler:若 context 無 actor,fallback dev headers X-Tenant-ID + X-UID(本機開發) → handler → logic → usecase ``` **目標完整鏈(Casbin / permission 模組就緒後):** ``` Request → go-zero JWT 驗簽 → JwtRevokeMiddleware(jti 黑名單 + auth_gen) → TenantContextMiddleware(校驗 tenant_id 一致) → CasbinRBACMiddleware(tenant_id × role_key × path × method → Allow) → handler → logic → usecase ``` ### 8.2 CasbinRBACMiddleware > Platform Admin bypass 在前一層 `JwtRevokeMiddleware` 第 0 步處理(§4.6),此處不重複。 ```go // ?代? roleKeys, _ := userRoleUC.GetRoleKeys(ctx, tenantID, uid) var hits []rbac.CheckResult for _, roleKey := range roleKeys { res, _ := rbacUC.Check(ctx, &rbac.CheckRequest{ TenantID: tenantID, UID: uid, RoleKey: roleKey, Path: r.URL.Path, Method: r.Method, }) if res.Allow { hits = append(hits, res) } } 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 ``` Request → ScimAuthMiddleware(tenant_scim_token) → TenantContextMiddleware → handler ``` ### 8.4 Logic 層補充授權 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: "" }` --- ## 9. 核心流程 ### 9.1 登入 / 換票 #### 9.1.1 Email + 密碼登入(已實作) ``` Client → POST /api/v1/auth/login { tenant_slug, email, password } 1. tenant.ResolveBySlug 2. zitadel.VerifyPassword(ROPG) 3. 解析 id_token / userinfo → zitadel sub 4. member.GetByZitadelUserID → 校驗 member_status == active 5. auth.IssuePair Client ← { access_token, refresh_token, uid } ``` #### 9.1.2 ZITADEL id_token 換票(SSO / 舊 client,已實作) ``` Client → POST /api/v1/auth/token/exchange { tenant_slug, id_token } 1. zitadel.VerifyIDToken(JWKS 驗簽 + iss/aud/exp) 2. tenant.ResolveBySlug 3. member.GetByZitadelUserID → 校驗 active 4. auth.IssuePair Client ← { access_token, refresh_token, uid } ``` #### 9.1.3 OIDC 登入 + JIT(B2B / 舊 B2C Hosted UI 路徑,仍支援) ``` 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.EnsureFromOIDC → uid(如 AMEX-10000000) // 若 member 不存在則 JIT 4. permission.SyncFromZitadelClaims → user_roles // 規劃中 5. auth.IssueTokenPair Client ← { access_token, refresh_token, uid } ``` > **注意**:B2C 新註冊應走 §3.4 Gateway `/auth/register*`;`/auth/token/exchange` 保留給 **已存在 member** 的 SSO 登入與企業 IdP。 ### 9.2 受保護 API ``` Client → GET /api/v1/members/me (Bearer access_jwt) 1. JWT + 黑名單 + auth_gen 2. CasbinRBACMiddleware → Check(role, "/api/v1/members/me", "GET") 3. member.GetByUID ``` ### 9.3 B2B 自定義 Role + 勾選 Permission ``` 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) + 廣播 reload(見 §6.11) Tenant Admin → POST /api/v1/permissions/users/{uid}/roles { "role_id": "..." } → UserRoleUC.Assign(tenantID, uid, roleID, source=manual) → INCR auth_gen + DEL perm:user_roles cache ``` ### 9.4 停權 ``` 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) ``` --- ## 10. LDAP 與 SCIM ### 10.1 三條 Provisioning 路徑 | 路徑 | 適用 | 說明 | |------|------|------| | **SCIM → ZITADEL → Gateway** | 有 HR / Entra ID / Okta | 企業推送使用者 | | **ZITADEL LDAP IdP** | 用戶登入時 JIT | 首次登入建立 member | | **Directory Sync Worker** | 無 SCIM 的 AD / OpenLDAP | 定時同步 + 離職偵測 | ### 10.2 LDAP 設定(AD + OpenLDAP) ```go type TenantLDAPConfig struct { TenantID string Type string // "ad" | "openldap" Host string Port int UseTLS bool BaseDN string BindDN string // encrypted BindPassword string // encrypted UserFilter string GroupFilter string AttrMap LDAPAttrMap } type LDAPAttrMap struct { Username string // AD: sAMAccountName / LDAP: uid Email string // mail DisplayName string // displayName / cn Phone string // telephoneNumber ExternalID string // objectGUID / entryUUID Groups string // memberOf } ``` ### 10.3 SCIM - **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 誤判保護(已決策) | 機制 | 設定 | 行為 | |------|------|------| | 連續找不到才停權 | `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 / 高異動率 / 連續失敗時通知 | > Worker 啟動順序:拉 LDAP snapshot → 計算 diff → 跑 guardrail 檢查(threshold + ratio)→ commit 或轉 dry-run → 寫 audit log。 --- ## 11. Notification Module 路徑:`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(含業務驗證旗標、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` + immutable `key`) | | `role_permissions` | permission | Role ? Permission ID | | `user_roles` | permission | uid ? Role(支援多角色) | | `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) | ### 13.2 主要索引 ```javascript // members { tenant_id: 1, uid: 1 } // unique { tenant_id: 1, zitadel_user_id: 1 } // unique { tenant_id: 1, member_status: 1, create_at: -1 } // identities { tenant_id: 1, zitadel_user_id: 1 } // unique { tenant_id: 1, uid: 1 } { tenant_id: 1, external_id: 1 } // permissions(全局) { name: 1 } // unique { parent: 1, status: 1 } { http_path: 1, http_method: 1 } // sparse // roles { tenant_id: 1, key: 1 } // unique { tenant_id: 1, status: 1 } // role_permissions { tenant_id: 1, role_id: 1, permission_id: 1 } // unique // user_roles { tenant_id: 1, uid: 1, role_id: 1 } // unique { tenant_id: 1, uid: 1 } // 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 ``` > 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 } ``` 單租戶 50 萬會集中在同一 chunk,MongoDB 仍可承受;若預期單租戶千萬級再評估 hash 二次分片。 --- ## 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`) ``` 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:{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 keys,TTL 5min ``` --- ## 15. 規模與性能(100 萬+ / 單租戶 50 萬) | 項目 | 策略 | |------|------| | Gateway | 無狀態,水平擴展 | | MongoDB | Sharding + Replica Set,讀走 secondary | | ListMembers | Cursor 分頁,禁止 deep offset | | Authorize | Casbin EnforceEx(?存 + Redis policy) | | LoadPolicy | ?更?增量;cron 5min 全量兜底 | | JWT → UID | Redis cache 1h | | Directory Sync | 500 users / batch,rate limit ZITADEL API | | Access Token TTL | 15min(降低撤銷窗口) | ### 容量粗估 ``` 100 萬 members × ~2KB ? 2GB(不含 index) indexes ? 1~2GB → 單集群可承受,建議 3 node replica set 起跳 ``` --- ## 16. 目錄結構 ``` gateway/ ├── generate/api/ │ ├── auth.api │ ├── member.api │ ├── permission.api │ ├── tenant.api │ └── scim.api │ ├── internal/ │ ├── middleware/ │ │ ├── jwt_revoke.go │ │ ├── tenant_context.go │ │ ├── require_permission.go │ │ └── scim_auth.go │ │ │ ├── library/ │ │ ├── zitadel/ │ │ │ ├── oidc.go │ │ │ └── management.go │ │ ├── ldap/ │ │ │ ├── client.go │ │ │ └── attrmap.go │ │ ├── casbin/ # Enforcer 初始化 helper │ │ ├── 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/ # 含 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 │ │ │ ├── role.go │ │ │ ├── user_role.go │ │ │ ├── role_permission.go │ │ │ └── role_mapping.go │ │ ├── enum/ │ │ │ ├── status.go │ │ │ └── permission_type.go │ │ ├── repository/ │ │ │ ├── permission.go │ │ │ ├── role.go │ │ │ ├── user_role.go │ │ │ ├── role_permission.go │ │ │ ├── role_mapping.go │ │ │ └── casbin_redis_adapter.go # 沿用 permission-server │ │ ├── usecase/ │ │ │ ├── permission_tree.go # 沿用 permission-server │ │ │ ├── rbac.go # Casbin LoadPolicy / Check │ │ │ ├── permission.go │ │ │ ├── role.go │ │ │ ├── role_permission.go │ │ │ ├── user_role.go │ │ │ ├── role_mapping.go │ │ │ └── authorization_query.go │ │ ├── rbac/ │ │ │ └── rule.go # Casbin Rule struct │ │ ├── config/ │ │ ├── errors.go │ │ ├── redis.go │ │ └── mock/ │ │ │ └── worker/ │ ├── directory_sync/ │ ├── policy_sync/ # 可選:定時 LoadPolicy │ ├── notification_retry/ # 異步重試、DLQ 巡檢 │ └── member_anonymize/ # 軟刪 30 天後匿名化(§5.7) │ ├── etc/ │ ├── gateway.yaml │ └── rbac.conf # Casbin 模型(沿用 permission-server) │ └── docs/ ├── model.md └── identity-member-design.md # 本文件 ``` --- ## 17. 設定檔 `etc/gateway.yaml` 擴充草案: ```yaml Name: gateway Host: 0.0.0.0 Port: 8888 Auth: AccessSecret: ${JWT_ACCESS_SECRET} AccessExpire: 900 RefreshAuth: AccessSecret: ${JWT_REFRESH_SECRET} AccessExpire: 604800 Zitadel: Issuer: https://id.internal.example.com # self-hosted 內網 ClientID: ${ZITADEL_CLIENT_ID} 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 設定 Redis: Host: 127.0.0.1:6379 Type: node Member: DefaultLanguage: zh-tw DefaultCurrency: TWD Permission: RBACModelPath: etc/rbac.conf PolicySyncInterval: 5m 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 ``` --- ## 18. 實施順序 | 階段 | 內容 | 產出 | |------|------|------| | **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)+ §10.4 guardrail | 企業目錄同步(誤判保護完備) | | **P8** | SCIM 2.0 endpoint + Group 映射 | 企業 provisioning | | **P8.5** | Audit log sink(Mongo 獨立 collection)+ Rate Limit middleware(見 §20) | 可審計 / 防濫用 | | **P9** | 壓測(100 萬 seed)、sharding、調優、JWT kid 多版本驗證 | 上線準備 | --- ## 19. 已決策事項 | # | 議題 | **決策** | 設計影響 | |---|------|----------|----------| | 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 | 註冊路徑 | **B2C**:Gateway 統一 `/auth/register*`(Email + Social,invite 必填);**B2B**:LDAP / SCIM 不經 register API;platform-native usecase 已用於 Email 註冊 | §3.4、[auth-unified-registration.md](./auth-unified-registration.md) | | 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 | --- ## 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(純 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 RoleUC permusecase.RoleUseCase RolePermUC permusecase.RolePermissionUseCase UserRoleUC permusecase.UserRoleUseCase RoleMappingUC permusecase.RoleMappingUseCase AuthQueryUC permusecase.AuthorizationQueryUseCase } ``` --- ## 附錄 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` + `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` | 加入 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` | 加 `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 | --- ## 修訂紀錄 | 日期 | 版本 | 說明 | |------|------|------| | 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 | | 2026-05-21 | 1.0.0 | **Gateway 統一註冊已實作**:修訂 §3.4(改為暴露 `/auth/register*`);§7.1 補齊已實作 auth 路由;§8.1 記載 CloudEP JWT + dev header fallback;§9.1 拆分 login / token exchange;§5.9 Case A 標為已實作;決策列 19 更新。詳見 [auth-unified-registration.md](./auth-unified-registration.md) |