template-monorepo/docs/identity-member-design.md

2631 lines
107 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Identity / Member / Permission 模組設計草稿
> **狀態**Draft待 Review
> **適用專案**Portal API GatewayPGW
> **參考實作**[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 SyncAD + 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 Syncread-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 = 身份級驗證**:登入 MFATOTP / 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、RoleB2B 自定義)│
│ 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}/usecaseinterface
repository → MongoDB / Redis
logic 不 import entity / repository見 model.md
auth → memberEnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM
auth → permissionSyncRolesFromClaims
auth → member.TOTPUseCasestep-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 寄送 |
| 業務 TOTPAuthenticator | — | — | ? 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 註冊 API
Gateway **不暴露** `/auth/register`。註冊由下列路徑完成:
| 租戶類型 | 註冊路徑 | 首次登入副作用 |
|---------|----------|----------------|
| **B2C** | ZITADEL Hosted Register UI或前端走 ZITADEL OIDC PKCE | token exchange 觸發 `EnsureFromOIDC` JIT |
| **B2BLDAP** | 由 IT 在 AD / OpenLDAP 建帳;可選 Directory Sync 預 provision 到 ZITADEL | LDAP IdP 登入觸發 `EnsureFromLDAP` JIT |
| **B2BSCIM** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway不需 JIT |
> ZITADEL 內建 email 驗證已完成「**可登入**」門檻;業務上「**可使用功能**」門檻見 §5.4 業務驗證。
### 3.5 平台 MFA 強制(已決策)
- ZITADEL Org Policy 設定:**任何 admin 級 role**`tenant_owner` / `tenant_admin` / `platform_super_admin`)登入時強制 TOTP / WebAuthn
- 一般 user 預設不強制(避免 B2C 流失)
- 高風險業務操作 → 走 Gateway Step-up MFA§5.6),與 ZITADEL 身份 MFA **互不取代**
---
## 4. auth 模組
路徑:`internal/model/auth/`
### 4.1 職責
- 驗證 ZITADEL OIDC tokenid_token / authorization_code + PKCE
- 編排 `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM``permission.SyncRolesFromClaims`
- 簽發 CloudEP JWTaccess + 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_jwttyp=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
```
- TTL5 分鐘
- 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. 注入 contexttenant_id, uidrole keys 由下一層 CasbinRBACMiddleware 從 cache 載入)
```
---
## 5. member 模組
路徑:`internal/model/member/`
### 5.1 職責
- 會員 Profile CRUDtenant-scoped
- Identity 映射(`zitadel_sub` ? `uid`
- Tenant metadata 與 LDAP 同步設定
- UID 產生(可讀格式)
- SCIM 業務寫入SCIM `id` / Gateway UID + 客戶端 `externalId`
- Directory Sync WorkerAD + 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 → activecaller 須先確保所有前置驗證已通過
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 上 upsertB2C / 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)
}
// ──────────────────────────────────────────────────────────
// OTPatomic、purpose-agnostic 一次性密碼
// 不寄信、不更新 membercaller 拿 code 後自行透過 NotifierUseCase 投遞
// ──────────────────────────────────────────────────────────
type OTPUseCase interface {
// 生成bcrypt 存 redis回 challenge_id + 明碼 code一次性回傳
Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, error)
// 驗證:成功則 invalidatepurpose 必須與 challenge 建立時一致
Verify(ctx context.Context, req *VerifyOTPRequest) error
// 主動失效(換 challenge / 取消註冊)
Invalidate(ctx context.Context, tenantID, challengeID string) error
}
// ──────────────────────────────────────────────────────────
// TOTPAuthenticator 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 claimOIDC 通常 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 disable30 天後匿名化§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 replaceGateway 不直接編輯 |
> 推論:`Member.Origin` 標主來源對應「Provisioning」欄位類別的可寫範圍。Gateway UI 改業務欄位永遠可行;改身份/Provisioning 欄位需走來源系統。
### 5.4 業務級驗證模型(已決策)
```go
// Member 既有 + 本節新增欄位
type Member struct {
TenantID string
UID string
ZitadelUserID string // ZITADEL subOIDC / 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僅存 RedisTTL 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, &notification.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 logNotification 自己也會記送達狀態,兩者互補)
- **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_idcode 由使用者從 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 }
- totpmember.TOTPUseCase.VerifyCode(uid, code, window=±1)
- sms/emailbcrypt 比對 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: <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.RevokeAllForUserINCR 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 TOTPAuthenticator App已決策啟用
業務級 TOTPGateway **自己存 secret**,與 ZITADEL 身份級 TOTP **獨立**(兩個獨立綁定,使用者首次 setup 需各掃一次 QR
> 為什麼分開ZITADEL TOTP 是登入用、secret 在 ZITADELGateway step-up TOTP 用於業務操作、secret 在 Gateway避免 Gateway 對 ZITADEL 私有資料的依賴與耦合。
#### Member 欄位(補充 §5.4
```go
type Member struct {
// ... 既有欄位
TOTPEnrolled bool
TOTPSecretCipher string // AES-GCM(secret, KEK)AES-256KEK 走 KMS / secret manager
TOTPEnrolledAt int64
TOTPBackupCodesHash []string // bcrypt(code)10 組一次性備援碼,用過即抹除
}
```
> Secret 必須對稱加密儲存,**禁止**明碼或單純 base32。KEK 走 KMS / Vaultrotation 時逐筆 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. Verifystep-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: <action=disable_totp>
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 code10 組、12 字 hex48-bit entropy、bcrypt cost 10、明碼僅 enroll 時回傳一次
#### API補充 §7.2
| Method | Path | 說明 | Step-up |
|--------|------|------|---------|
| POST | `/api/v1/members/me/totp/enroll-start` | 取 otpauth URL + QR | — |
| POST | `/api/v1/members/me/totp/enroll-confirm` | 驗第一組 code啟用 + 回 backup codes | — |
| GET | `/api/v1/members/me/totp` | 取 TOTP 狀態enrolled? backup 剩餘數) | — |
| POST | `/api/v1/members/me/totp/backup-codes` | 重產 backup codes | ? `disable_totp` |
| DELETE | `/api/v1/members/me/totp` | 解除綁定 | ? `disable_totp` |
### 5.9 UseCase 編排示例純概念handler / API 暫不實作)
> 展示 atomic primitives 可任意組合的邏輯流。**logic 層尚未實作**;本節僅證明介面契約可支撐預期業務。
#### Case A平台原生註冊 + Email OTP 驗證(未來路徑)
```go
// 1) 建立 unverified member不寄信、不發 token
m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{
TenantID: tenantID, Email: email, DisplayName: name,
})
// 2) 產生 OTPatomic、purpose-agnostic
chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{
TenantID: tenantID,
Purpose: OTPPurposeRegistrationEmail,
Identifier: m.UID,
})
// 3) 投遞 OTPatomiccaller 控制 channel / template
notifier.Send(ctx, &SendRequest{
TenantID: tenantID,
UID: m.UID,
Channel: ChannelEmail,
Kind: NotifyVerifyRegistrationEmail,
Target: email,
Data: map[string]any{"code": chal.Code, "expires_in": chal.ExpiresIn},
IdempotencyKey: chal.ChallengeID,
DoNotPersistBody: true,
})
// (使用者收信、輸入 code → 後端走以下兩步)
// 4) 驗證 OTPatomic
_ = mOTP.Verify(ctx, &VerifyOTPRequest{
TenantID: tenantID, ChallengeID: chal.ChallengeID,
Code: userCode, Purpose: OTPPurposeRegistrationEmail,
})
// 5) 啟用atomicunverified → active
_ = mLifecycle.Activate(ctx, tenantID, m.UID)
```
#### Case BOIDCSocial / 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 CLDAP 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 DSCIM Create User — 不需 OTP
```go
m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{
TenantID: tenantID, ExternalID: scimExternalID,
UserName: username, Email: email, Active: true, RawPayload: rawJSON,
})
```
#### Case E已登入 user 改綁業務 emailatomic 直組 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 // 父權限 IDObjectID 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 EntityB2B 租戶自定義)
```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 管理員範例:
```
建立 Rolesales_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 一 roleUpdate 覆蓋);新設計**支援多角色**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 Listtenant-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 usecaseRole*, RolePermission*, UserRole*, RoleMapping*)進入時必須驗證 target ID 屬於 `tenantID`repository 查詢一律帶 `{tenant_id, _id}`,找不到回 `ErrRoleNotInTenant` / `ErrUserRoleNotInTenant`。
> Logic 層**禁止**把 path 的 `:id` 直接丟 usecase 而不帶 `tenant_id`。
### 6.9 Middleware 授權流程
```
RequestJwtRevokeMiddleware 已驗過 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 policyCheck 時主 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 新的 roleIDssource='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 勾選 APICasbin 直接載入 seed 結果)。
---
## 7. API 規劃
檔案:`generate/api/`
### 7.1 auth.api公開 / 需 JWT 視 API 而定)
| Method | Path | 說明 | 鑑權 |
|--------|------|------|------|
| POST | `/api/v1/auth/token/exchange` | ZITADEL token → CloudEP JWT | 公開 |
| POST | `/api/v1/auth/token/refresh` | 刷新 JWT | 公開(帶 refresh |
| POST | `/api/v1/auth/logout` | 登出jti 黑名單) | JWT |
| POST | `/api/v1/auth/revoke-all` | 撤銷自己所有 sessionINCR 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 Treeopen 節點) |
| GET | `/api/v1/permissions/me` | 當前使用者的 permission name → status map |
| GET | `/api/v1/permissions/roles` | 列出租戶 Role |
| POST | `/api/v1/permissions/roles` | 建立 RoleB2B |
| 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.apiSCIM 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
```
Request
→ go-zero JWT 驗簽
→ JwtRevokeMiddlewarejti 黑名單 + auth_gen
→ TenantContextMiddleware校驗 tenant_id 一致)
→ CasbinRBACMiddlewaretenant_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
→ ScimAuthMiddlewaretenant_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: "<action>" }`
---
## 9. 核心流程
### 9.1 登入 / 換票
```
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
4. permission.SyncFromZitadelClaims → user_roles
5. auth.IssueTokenPairrole keys 快照, auth_gen
Client ← { access_token, refresh_token, uid }
```
### 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: <step_up_token, action=tenant_admin_force_status>
1. Casbin enforce 命中 member.admin.status
2. Logic 驗 step_up_token + action 一致
3. member.UpdateStatus
4. auth.RevokeAllForUserINCR 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 Providertemplate 為 `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: <step_up_token>
1. Middleware 通過(一般 JWT + Casbin
2. Logic step-up 守門(見 §8.4
3. 重設 BusinessEmailVerified = falseBusinessEmail = 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 + 入 channelworker 拉走重試;高吞吐用
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` collectionadmin 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 keyE11000→ 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 millisecondsUTC**。對外 SCIM `meta.created` / `meta.lastModified` 由 SCIM mapper 在序列化時轉 RFC3339Nano前端展示由 client 負責 timezone。
### 13.3 分片鍵100 萬+
```
Shard Key: { tenant_id: 1, uid: 1 }
```
單租戶 50 萬會集中在同一 chunkMongoDB 仍可承受;若預期單租戶千萬級再評估 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 cacheTTL 5~15min
member:sub:{tenant_id}:{sub} # zitadel_sub → uidTTL 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} # 單日上限 INCRTTL 24h
totp:enroll:{tenant_id}:{uid} # enroll 暫存 secret_cipherTTL 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} # 每租戶每通道 quotaINCR + TTL
notif:retry:zset # 異步重試排程score = next_retry_at_ms
```
### permission`internal/model/permission/redis.go`
```
permission:casbin:rules:{tenant_id} # Casbin policy rulesList of JSON
permission:tree:open # 可選open 節點 cache
perm:role_perms:{tenant_id}:{role_id} # role → permission namesTTL 30min
perm:user_roles:{tenant_id}:{uid} # uid → role keysTTL 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 / batchrate 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 級 roletenant_owner/tenant_admin/platform_super_admin強制 TOTP
# Self-hostedLDAP 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 + ProvisioningUseCaseOIDC/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 WorkerAD + OpenLDAP+ §10.4 guardrail | 企業目錄同步(誤判保護完備) |
| **P8** | SCIM 2.0 endpoint + Group 映射 | 企業 provisioning |
| **P8.5** | Audit log sinkMongo 獨立 collection+ Rate Limit middleware見 §20 | 可審計 / 防濫用 |
| **P9** | 壓測100 萬 seed、sharding、調優、JWT kid 多版本驗證 | 上線準備 |
---
## 19. 已決策事項
| # | 議題 | **決策** | 設計影響 |
|---|------|----------|----------|
| 1 | UID 格式 | **`{Prefix}-{Sequence}`**,如 `AMEX-10000000` | §12Sequence 起跳 `10000000` |
| 2 | SCIM 路由 | **`/scim/v2/tenants/{tenant_id}/...`** | §7.5、§10.3 |
| 3 | ZITADEL 部署 | **Self-hosted** | §3.3LDAP 內網/VPN 連線 |
| 4 | 權限變更生效 | **UserRole 變更 `INCR auth_gen`RolePermission 變更 reload policy + cache invalidate** | §4.5、§6.11 |
| 5 | B2C 租戶 | **唯讀 seed 模板**,不可自定義 Role | §6.12B2C 禁用 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.3Gateway UID 作為 SCIM id 或 extension |
| 9 | Platform Admin bypass | **平台 role + allowlist必須 audit** | §6.7、§8.2;不放在 Casbin matcher |
| 10 | UIDPrefix | **全平台唯一**`tenants.uid_prefix` unique index | §12.2 |
| 11 | JWT Claims 內容 | **不放 role / permission 快照**,每次查 cache | §4.3 |
| 12 | Refresh Token Reuse | **舊 refresh 二次使用 = 盜用 → INCR auth_gen + audit** | §4.5 |
| 13 | Token Exchange 防重放 | **id_token nonce SETNX + iat 5 分鐘窗口** | §4.5 |
| 14 | Logout 對應 | **Issue 時 redis 記 access?refresh jti pair** | §4.5 |
| 15 | RolePermission API 語意 | **PUT 全量取代** `{ permission_names: [...] }` + 強制帶 tenant_id | §6.8、§7.3、§9.3 |
| 16 | 外部來源 UserRole | **按 source 隔離 Replace**manual 永不被洗 | §6.10 |
| 17 | PlainCode 實作 | **Casbin 額外查 `.plain_code` 變體**,多 role allow 結果取 OR | §6.9 |
| 18 | Permission.Name | **建立後不可改名**;廢棄走 `status=close` + 新建 | §6.4 |
| 19 | 註冊路徑 | **預設**走 ZITADEL Hosted UIB2C/ LDAP / SCIMB2B**保留** platform-native usecase`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`)供未來開通 Gateway 原生註冊(含 email OTP 驗證) | §3.4、§5.2.1、§5.9 |
| 20 | 身份 vs 業務驗證分層 | **ZITADEL 管登入身份Gateway member 自驗業務 email / phone** | §1.2、§5.4 |
| 21 | Step-up MFA | **啟用**;高風險 action 需 5min 單次性 `step_up_token` | §5.6、§9.6 |
| 22 | OTP 投遞通道 | **自送**(透過 Notification Module 包 Email / SMS Provider | §5.5、§11、§17 |
| 23 | MFA 強制策略 | **平台強制 admin role 走 ZITADEL TOTP**;一般 user 預設不強制,高風險走 Step-up | §3.5 |
| 24 | KYC | **不在初版範圍** | — |
| 25 | 業務 TOTPAuthenticator 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 紀錄僅留 metadatatarget_hash、provider_message_id、status | §11.3、§11.8 |
| 29 | UseCase 分層 | **Atomic primitives + Composite** 兩層原子動作Profile / Lifecycle / Provisioning / OTP / TOTP可任意組合CompositeVerification / 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 rotationv2 再加 `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 disable30 天 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 600rpmOTP 走 §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: ZSETtimestamp_ms : nonceTTL = 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 程式碼規範)
實作時兩份文件搭配使用。
---
## 附錄 BServiceContext 組裝草案
```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
}
```
---
## 附錄 Cpermission-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 + permissionB2B 自定義)+ ZITADEL/LDAP/SCIM |
| 2026-05-19 | 0.2.0 | 對齊 app-cloudep-permission-serverCasbin RBAC、Permission Tree、Role/RolePermission |
| 2026-05-19 | 0.3.0 | 已定案 §1916UID 前綴格式、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.5Step-up MFA 啟用§5.6、§9.6OTP 自送 Email + SMS Provider§5.5、§17 Notification平台 admin 強制 ZITADEL TOTP§3.5);新增對應 Redis key、API、設定、決策列 1924 |
| 2026-05-20 | 0.7.0 | 待決策 AL 全數拍板SCIM id = Gateway UID + ZITADEL sub extension§10.3Casbin 多 pod Pub/Sub + 5min cron 兜底§6.11Tenant 建立 saga§3.1Platform Admin seed CLI§18 P0Member.Origin + UserRole.Source 雙欄§5.4、§6.10SCIM token 全權 + IP allowlist§7.5);獨立 audit_logs collection + TTL 90d§20.1);軟刪 30 天匿名化§5.7);分欄位 SoT§5.3Directory Sync guardrail§10.4Redis sliding-window rate limit§20.2JWT 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、設定檔、實施順序、決策列 2528 同步更新§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 修正、新增 2932 |