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

2628 lines
118 KiB
Markdown
Raw Normal View History

2026-05-19 13:56:59 +00:00
# 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)
2026-05-19 17:04:26 +00:00
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)
2026-05-19 13:56:59 +00:00
---
## 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 萬 |
2026-05-19 17:04:26 +00:00
| UID | 人類可讀、帶租戶前綴,如 `AMEX-10000000`;唯一性以 `tenant_id + uid` 為準 |
2026-05-19 13:56:59 +00:00
### 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
2026-05-19 17:04:26 +00:00
- 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
2026-05-19 13:56:59 +00:00
---
## 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/ │
2026-05-19 17:04:26 +00:00
│ auth/ → Token 簽發、換票、登出、黑名單、auth_gen、step-up│
│ member/ → Profile、Identity、Tenant、UID、Sync、TOTP、驗證│
│ permission/ → Casbin RBAC、Permission Tree、RoleB2B 自定義)│
│ notification/ → Email/SMS/Push 統一發送、模板、重試、audit │
2026-05-19 13:56:59 +00:00
├─────────────────────────────────────────────────────────────────┤
│ internal/library/ │
2026-05-19 17:04:26 +00:00
│ zitadel/ · ldap/ · uid/ · casbin/ │
│ notification/email · notification/sms · notification/push │
2026-05-19 13:56:59 +00:00
├─────────────────────────────────────────────────────────────────┤
│ internal/worker/ │
2026-05-19 17:04:26 +00:00
│ directory_sync/ · notification_retry/ · member_anonymize/ │
2026-05-19 13:56:59 +00:00
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
MongoDB Redis ZITADEL
(profile/role) (cache/blacklist) (identity/LDAP IdP)
2026-05-19 17:04:26 +00:00
+
Email / SMS Provider
2026-05-19 13:56:59 +00:00
```
### 2.1 模組依賴方向
```
2026-05-19 17:04:26 +00:00
handler → logic → model/{auth|member|permission|notification}/usecaseinterface
2026-05-19 13:56:59 +00:00
repository → MongoDB / Redis
logic 不 import entity / repository見 model.md
2026-05-19 17:04:26 +00:00
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 實作)
2026-05-19 13:56:59 +00:00
```
---
## 3. 外部系統分工
2026-05-19 17:04:26 +00:00
| 能力 | 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 | 同步異常告警 |
2026-05-19 13:56:59 +00:00
### 3.1 多租戶對應
```
1 CloudEP Tenant = 1 ZITADEL Organization = 1 資料隔離邊界
```
| 欄位 | 來源 | 用途 |
|------|------|------|
| `tenant_id` | ZITADEL `org_id` | 分片鍵、授權邊界 |
| `identity_id` | ZITADEL `sub` | 身份映射 |
2026-05-19 17:04:26 +00:00
| `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")` 重試或告警。
2026-05-19 13:56:59 +00:00
### 3.2 租戶類型
| 類型 | 登入 | LDAP | 權限 |
|------|------|------|------|
| **B2C** | Email / Social | 無 | 系統預設 Role不可或不常自定義 |
| **B2B** | ZITADEL → LDAP IdP | 有 | **完全自定義 Role + Permission** |
2026-05-19 17:04:26 +00:00
| **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 **互不取代**
2026-05-19 13:56:59 +00:00
---
## 4. auth 模組
路徑:`internal/model/auth/`
### 4.1 職責
- 驗證 ZITADEL OIDC tokenid_token / authorization_code + PKCE
2026-05-19 17:04:26 +00:00
- 編排 `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM``permission.SyncRolesFromClaims`
2026-05-19 13:56:59 +00:00
- 簽發 CloudEP JWTaccess + refresh
2026-05-19 17:04:26 +00:00
- **簽發 Step-up Token**(高風險操作用,短壽命 5min見 §5.6
2026-05-19 13:56:59 +00:00
- 登出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
}
2026-05-19 17:04:26 +00:00
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 // 單次性
}
2026-05-19 13:56:59 +00:00
```
### 4.3 CloudEP JWT Claims
```go
type Claims struct {
jwt.RegisteredClaims // 含 jti, exp, iat
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
}
```
2026-05-19 17:04:26 +00:00
> **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已決策
2026-05-19 13:56:59 +00:00
```yaml
Auth:
AccessExpire: 900 # 15 分鐘
2026-05-19 17:04:26 +00:00
ActiveKID: v2 # 當前簽發用 kid
Keys: # 驗證可接受的 kid 名單(含正在退役的)
- kid: v1
Secret: ${JWT_ACCESS_SECRET_V1}
- kid: v2
Secret: ${JWT_ACCESS_SECRET_V2}
2026-05-19 13:56:59 +00:00
RefreshAuth:
AccessExpire: 604800 # 7 天
2026-05-19 17:04:26 +00:00
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}
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
**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 共用此架構
2026-05-19 13:56:59 +00:00
`.api` 受保護路由:
```api
2026-05-19 17:04:26 +00:00
@server(jwt: Auth, middleware: JwtMultiKeyMiddleware,JwtRevokeMiddleware)
2026-05-19 13:56:59 +00:00
```
### 4.5 黑名單策略(只黑名單 JWT
2026-05-19 17:04:26 +00:00
#### 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
```
2026-05-19 13:56:59 +00:00
#### 單 Token 撤銷(登出)
```
Key: auth:jwt:bl:{jti}
Value: 1
TTL: token 剩餘有效時間exp - now
```
2026-05-19 17:04:26 +00:00
```
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
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
#### 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 / **權限變更**
2026-05-19 13:56:59 +00:00
```
Key: auth:gen:{tenant_id}:{uid}
Value: 整數,預設 1事件發生時 INCR
```
Middleware 檢查:`token.auth_gen >= redis.auth_gen`,否則 401。
2026-05-19 17:04:26 +00:00
> **已決策**UserRole 指派/撤銷、外部 Group 映射導致的 user role 變更 → **`INCR auth_gen`**(等效強制刷新,使用者需重新 exchange/refresh 取得新 auth_gen
>
> RolePermission 變更不改變「使用者有哪些角色」,只需 `LoadPolicy(tenant_id)` + 權限快取失效;若未來改成完全信任 JWT 內角色/權限快照,才需要同步 `INCR auth_gen`。
2026-05-19 13:56:59 +00:00
> JWT 內不放全部 permission避免 token 過大);批量失效用 `auth_gen`,單次登出用 jti 黑名單。
### 4.6 Middleware 檢查順序
```
2026-05-19 17:04:26 +00:00
0. Platform Admin allowlist 命中platform tenant + platform_super_admin role 或 break-glass UID
→ audit.LogPlatformBypass → 直接放行
2026-05-19 13:56:59 +00:00
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}
2026-05-19 17:04:26 +00:00
- redis key 不存在 → 視為 0
- 簽發 token 時 claims.auth_gen = redis.GET 或 0
5. 注入 contexttenant_id, uidrole keys 由下一層 CasbinRBACMiddleware 從 cache 載入)
2026-05-19 13:56:59 +00:00
```
---
## 5. member 模組
路徑:`internal/model/member/`
### 5.1 職責
- 會員 Profile CRUDtenant-scoped
- Identity 映射(`zitadel_sub` ↔ `uid`
2026-05-19 17:04:26 +00:00
- Tenant metadata 與 LDAP 同步設定
2026-05-19 13:56:59 +00:00
- UID 產生(可讀格式)
2026-05-19 17:04:26 +00:00
- SCIM 業務寫入SCIM `id` / Gateway UID + 客戶端 `externalId`
2026-05-19 13:56:59 +00:00
- Directory Sync WorkerAD + OpenLDAP
2026-05-19 17:04:26 +00:00
- 會員狀態active / suspended / deleted→ 通知 auth 撤銷 token
- **業務級驗證**business email / phone 綁定 + OTP 自送
- **Step-up MFA OTP 驗證**(搭配 auth 模組簽 step_up_token
2026-05-19 13:56:59 +00:00
### 5.2 UseCase 介面
2026-05-19 17:04:26 +00:00
> **設計原則(呼應 model.md**:每個 UseCase 是**原子業務操作****不假設前後步驟存在**。流程編排(如「註冊 → 寄驗證信 → 啟用」)由 **logic 層**用多個 UseCase 拼裝;本層只負責單一動作 + 副作用。
>
> 介面分兩層:
> 1. **Atomic primitives**:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification。Logic 可任意組合,跨流程共用。
> 2. **Composite**:把幾個常用 atomic 預先組好的「快捷組合」(如 `VerificationUseCase` = `OTP.Generate` + `Notifier.Send` + `Member.SetVerified`。Composite 是**可選**logic 也可以繞過直接組 atomic。
>
> 業務邏輯API、handler、流程編排目前**不實作**;先固化介面契約。
#### 5.2.1 Atomic primitives
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
```go
// ──────────────────────────────────────────────────────────
// Profile讀寫 member 欄位(不含啟用 / 停權等狀態變遷)
// ──────────────────────────────────────────────────────────
2026-05-19 13:56:59 +00:00
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)
2026-05-19 17:04:26 +00:00
// 業務 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)
2026-05-19 13:56:59 +00:00
}
2026-05-19 17:04:26 +00:00
// ──────────────────────────────────────────────────────────
// 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
2026-05-19 13:56:59 +00:00
}
2026-05-19 17:04:26 +00:00
// ──────────────────────────────────────────────────────────
// 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
// ──────────────────────────────────────────────────────────
2026-05-19 13:56:59 +00:00
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
}
2026-05-19 17:04:26 +00:00
// ──────────────────────────────────────────────────────────
// SCIM Resource handlers
// ──────────────────────────────────────────────────────────
2026-05-19 13:56:59 +00:00
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)
}
```
2026-05-19 17:04:26 +00:00
#### 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 會員生命週期狀態
2026-05-19 13:56:59 +00:00
| 狀態 | 語意 | 副作用 |
|------|------|--------|
2026-05-19 17:04:26 +00:00
| `unverified` | **僅平台原生註冊**會出現member 已建立,但註冊 email 尚未通過 OTP 驗證 | 不簽 token、不可登入逾期由 cron `AbortPending` 清理 |
2026-05-19 13:56:59 +00:00
| `active` | 正常使用 | — |
2026-05-19 17:04:26 +00:00
| `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 自行決定組合與順序。
2026-05-19 13:56:59 +00:00
---
## 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父子節點繼承父節點關閉則子節點不可用 |
2026-05-19 17:04:26 +00:00
| **Casbin RBAC** | 以 `(tenant_id, role_key, http_path, http_method)` 做 API 授權path 支援 `keyMatch2` 萬用字元 |
2026-05-19 13:56:59 +00:00
| **B2B 自定義 Role** | 每個租戶建立自訂 Role從全局 Catalog **勾選** Permission不可自創 Permission 字串) |
2026-05-19 17:04:26 +00:00
| **UserRole** | 租戶 + uid + role支援多角色以 immutable role key 做 Casbin subject |
2026-05-19 13:56:59 +00:00
| **RolePermission** | 勾選子權限時自動補齊父權限 ID沿用 permission-server 的 `getFullParentPermissionIDs` |
| **Policy 同步** | MongoDB → Casbin Policy → Redis定時 `LoadPolicy` + 變更時觸發 reload |
2026-05-19 17:04:26 +00:00
| **外部映射** | ZITADEL Role / LDAP Group / SCIM Group → 租戶內部 Role.Key |
2026-05-19 13:56:59 +00:00
| **細粒度擴展** | 同一 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` | 建立者,可選 |
2026-05-19 17:04:26 +00:00
| `entity.Role.Name` | `Role.DisplayName` | 顯示名稱,可改名 |
| — | `Role.Key` | **Casbin policy 的 role 欄位**,租戶內唯一且不可改 |
2026-05-19 13:56:59 +00:00
| `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | 沿用 |
| `PermissionTree` | `usecase/permission_tree.go` | 沿用 |
2026-05-19 17:04:26 +00:00
| `AdminRoleUID` / `GodDog` | `PlatformAdminRoleKey` + allowlist | 平台超級管理員 bypass需 audit |
2026-05-19 13:56:59 +00:00
| `permission.Type` | `enum.PermissionType` | `BackendUser` / `FrontendUser` |
### 6.3 核心概念
```
Permission全局樹 平台定義,含 name / http_path / http_method / parent / status / type
2026-05-19 17:04:26 +00:00
Role租戶自定義 租戶建立的角色display_name 可改key 不可改,如 sales_supervisor、tenant_admin
2026-05-19 13:56:59 +00:00
RolePermission Role ↔ Permission ID 多對多;勾選時自動補父節點
UserRole uid ↔ Role一 user 可多 role
2026-05-19 17:04:26 +00:00
RoleMapping 外部 Group/Role → 內部 RoleID / Role.Key
Casbin Policy p, {tenant_id}, {role_key}, {http_path}, {http_method}, {permission.Name}
2026-05-19 13:56:59 +00:00
```
### 6.4 Permission Entity全局 Catalog
沿用 permission-server 的 `entity.Permission` 結構MongoDB collection`permission`。
```go
type Permission struct {
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
}
```
2026-05-19 17:04:26 +00:00
> **`Permission.Name` 一旦建立不可改名**(被 RolePermission、UI i18n 鍵、Casbin policy.name 欄位引用)。
> 廢棄走 `status=close`;新名稱另建新 leaf。重命名要走資料遷移腳本。
> **`HTTPPath` 限制**:避免裸 `*`;萬用路徑要明確標出資源根,例如 `/api/v1/members/*`,禁止 `/api/v1/*` 之類的廣域 pattern防 keyMatch2 貪婪命中)。
2026-05-19 13:56:59 +00:00
#### 命名規則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 {
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
}
2026-05-19 17:04:26 +00:00
// Index: { tenant_id, key } unique
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
> **`Role.Key` 規範**
> - 格式:`^[a-z][a-z0-9_]{1,63}$`
> - 租戶內唯一;建立後**不可修改**
> - 禁止 `system.` / `platform_` 字首(保留給平台級 role
> - rename 改 `DisplayName`,不影響 UserRole、RoleMapping、Casbin policy 與既有 token
2026-05-19 13:56:59 +00:00
#### 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
2026-05-19 17:04:26 +00:00
| Key | DisplayName | 預設勾選Permission Name |
2026-05-19 13:56:59 +00:00
|------|------|----------------------------|
| `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
2026-05-19 17:04:26 +00:00
指派POST /permissions/users/{uid}/roles { "role_id": "..." }
2026-05-19 13:56:59 +00:00
→ 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 {
2026-05-19 17:04:26 +00:00
TenantID string
2026-05-19 13:56:59 +00:00
RoleID string
PermissionID string
CreateAt int64
UpdateAt int64
}
2026-05-19 17:04:26 +00:00
// Index: { tenant_id, role_id, permission_id } unique
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
> 舊 permission-server 的 UserRole 為一 user 一 roleUpdate 覆蓋);新設計**支援多角色**Middleware 對每個 immutable role key 做 Casbin enforce任一 allow 即通過。
2026-05-19 13:56:59 +00:00
### 6.7 Casbin RBAC核心授權引擎
2026-05-19 17:04:26 +00:00
#### 模型檔 `etc/rbac.conf`Gateway 多租戶版)
2026-05-19 13:56:59 +00:00
```ini
[request_definition]
2026-05-19 17:04:26 +00:00
r = tenant, role, path, method
2026-05-19 13:56:59 +00:00
[policy_definition]
2026-05-19 17:04:26 +00:00
p = tenant, role, path, methods, name
2026-05-19 13:56:59 +00:00
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
2026-05-19 17:04:26 +00:00
m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods)
2026-05-19 13:56:59 +00:00
```
- **`keyMatch2`**:支援 `/api/v1/members/*` 萬用 path
- **`regexMatch`**:支援 `GET|POST` 多 method 寫在同一 policy
2026-05-19 17:04:26 +00:00
- **SuperAdmin bypass**:不放在 Casbin matcher由 Middleware 先驗證 platform role / allowlist 後短路,並寫入 audit log
2026-05-19 13:56:59 +00:00
#### Policy 載入(`RBACUseCase.LoadPolicy`
```
1. permissionRepo.GetAll → GeneratePermissionTree → filterOpenNodes
2. roleRepo.All(tenant_id) → 每個 role 取 rolePermissionRepo.Get
3. 對每個 (role, permission) 若 http_path + http_method 非空:
2026-05-19 17:04:26 +00:00
enforcer.AddPolicy(tenantID, role.Key, permission.HTTPPath, permission.HTTPMethods, permission.Name)
4. adapter.SavePolicy(tenant_id) → Redis Listtenant-scoped casbin rules
2026-05-19 13:56:59 +00:00
5. enforcer.LoadPolicy()
```
#### 授權檢查(`RBACUseCase.Check`
```go
2026-05-19 17:04:26 +00:00
// 輸入tenantID, roleKey, requestPath, requestMethod
ok, policy, err := enforcer.EnforceEx(tenantID, roleKey, path, method)
2026-05-19 13:56:59 +00:00
// 回傳 CheckRolePermissionStatus
// Allow: bool
// PermissionName: string // 命中的 permission.Name
// PlainCode: bool // 是否有 .plain_code 子權限GET 時額外查)
```
#### Policy 同步策略
| 觸發 | 動作 |
|------|------|
2026-05-19 17:04:26 +00:00
| RolePermission 變更 | 該 tenant `LoadPolicy` + 權限快取失效 |
| Permission status 變更(平台) | 全局 `LoadAllPolicies` + 權限快取失效 |
2026-05-19 13:56:59 +00:00
| 定時 cron如 5min | `SyncPolicy` 兜底 |
| Gateway 啟動 | 初始 `LoadPolicy` |
2026-05-19 17:04:26 +00:00
Redis 儲存 Casbin rules`permission:casbin:rules:{tenant_id}`List of JSON `rbac.Rule`)。全量載入時可掃描 tenant-scoped keys或由 repository 依 MongoDB role/permission 重建。
2026-05-19 13:56:59 +00:00
### 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
2026-05-19 17:04:26 +00:00
RoleKey string // immutable Role.Key
2026-05-19 13:56:59 +00:00
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 {
2026-05-19 17:04:26 +00:00
Get(ctx context.Context, tenantID, roleID string) (enum.Permissions, error) // name → open/close
Replace(ctx context.Context, tenantID, roleID string, permNames []string) error // 全量取代
2026-05-19 13:56:59 +00:00
}
// --- UserRole ---
type UserRoleUseCase interface {
GetByUID(ctx context.Context, tenantID, uid string) ([]UserRoleDTO, error)
2026-05-19 17:04:26 +00:00
GetRoleKeys(ctx context.Context, tenantID, uid string) ([]string, error) // Middleware 用,走 cache
Assign(ctx context.Context, tenantID, uid, roleID string, source enum.RoleSource) error
2026-05-19 13:56:59 +00:00
Revoke(ctx context.Context, tenantID, uid, roleID string) error
2026-05-19 17:04:26 +00:00
Replace(ctx context.Context, tenantID, uid string, roleIDs []string, source enum.RoleSource) error // 全量取代「該 source」的指派
2026-05-19 13:56:59 +00:00
}
// --- 外部映射 ---
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)
}
```
2026-05-19 17:04:26 +00:00
> **跨租戶防呆**:所有 mutation usecaseRole*, RolePermission*, UserRole*, RoleMapping*)進入時必須驗證 target ID 屬於 `tenantID`repository 查詢一律帶 `{tenant_id, _id}`,找不到回 `ErrRoleNotInTenant` / `ErrUserRoleNotInTenant`。
> Logic 層**禁止**把 path 的 `:id` 直接丟 usecase 而不帶 `tenant_id`。
2026-05-19 13:56:59 +00:00
### 6.9 Middleware 授權流程
```
2026-05-19 17:04:26 +00:00
RequestJwtRevokeMiddleware 已驗過 JWT + auth_gen
2026-05-19 13:56:59 +00:00
1. 取 ctx.tenant_id, ctx.uid
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
> **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),不進這個流程。
2026-05-19 13:56:59 +00:00
### 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
2026-05-19 17:04:26 +00:00
InternalRoleID string // 租戶 Role._id hex
InternalRoleKey string // denormalized Role.Key方便查詢與審計
2026-05-19 13:56:59 +00:00
}
// Index: { tenant_id, external_source, external_key } unique
```
| 來源 | ExternalKey 範例 | 映射到 |
|------|------------------|--------|
| ZITADEL | `org_admin` | `tenant_admin` |
2026-05-19 17:04:26 +00:00
| 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 |
2026-05-19 13:56:59 +00:00
由 B2B 租戶管理員在後台設定(需命中 `permission.role.write` 對應 API
2026-05-19 17:04:26 +00:00
#### 外部來源同步規則(避免洗掉 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。
2026-05-19 13:56:59 +00:00
### 6.11 權限變更生效
| 事件 | 動作 |
|------|------|
2026-05-19 17:04:26 +00:00
| RolePermission Create/Delete | `LoadPolicy(tenant_id)` + `perm:role_perms:*` 快取失效 |
2026-05-19 13:56:59 +00:00
| Role Create/Update/Delete | `LoadPolicy(tenant_id)` |
2026-05-19 17:04:26 +00:00
| 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 權限策略(已決策)
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
| 租戶類型 | Role 自定義 | Permission 勾選 | API 限制 |
|----------|-------------|-----------------|----------|
| **B2C** | **不可**(唯讀 seed 模板) | 固定,不可改 | 禁止 `POST/PUT/DELETE /permissions/roles*` |
| **B2B** | **完全自定義** | 從全局 Catalog 自由勾選 | 完整 permission API |
| **Hybrid** | 依 tenant.type 欄位判斷 | B2B 段可自定義 | middleware 檢查 tenant 類型 |
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
B2C 租戶建立時只 seed 固定 Role`member`、`viewer`**不提供** Role CRUD 與 Permission 勾選 APICasbin 直接載入 seed 結果)。
2026-05-19 13:56:59 +00:00
---
## 7. API 規劃
檔案:`generate/api/`
2026-05-19 17:04:26 +00:00
### 7.1 auth.api公開 / 需 JWT 視 API 而定)
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
| 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 |
2026-05-19 13:56:59 +00:00
### 7.2 member.api需 JWT + Casbin
2026-05-19 17:04:26 +00:00
| 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)。
2026-05-19 13:56:59 +00:00
### 7.3 permission.api需 JWT + Casbin
| Method | Path | 說明 |
|--------|------|------|
| GET | `/api/v1/permissions/catalog` | 全局 Permission Treeopen 節點) |
2026-05-19 17:04:26 +00:00
| GET | `/api/v1/permissions/me` | 當前使用者的 permission name → status map |
2026-05-19 13:56:59 +00:00
| 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 |
2026-05-19 17:04:26 +00:00
| PUT | `/api/v1/permissions/roles/:id/permissions` | 全量取代 Role 勾選 `{ "permission_names": [...] }`PermissionTree 驗證 + 補 parent |
| GET | `/api/v1/permissions/users/:uid/roles` | 查使用者角色 |
2026-05-19 13:56:59 +00:00
| 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
2026-05-19 17:04:26 +00:00
**已決策路由**:以 **`tenant_id`** 為 path 參數(不用子域名)
2026-05-19 13:56:59 +00:00
```
/scim/v2/tenants/{tenant_id}/Users
/scim/v2/tenants/{tenant_id}/Groups
2026-05-19 17:04:26 +00:00
/scim/v2/tenants/{tenant_id}/ServiceProviderConfig
/scim/v2/tenants/{tenant_id}/Schemas
2026-05-19 13:56:59 +00:00
```
認證:`Authorization: Bearer {tenant_scim_token}`hash 存於 tenant 設定)
2026-05-19 17:04:26 +00:00
- `{tenant_id}` = ZITADEL `org_id`,與 JWT `tenant_id` 一致
- SCIM 請求不走 CloudEP JWT授權由 tenant 級 SCIM token + 可選 Casbin 細分
2026-05-19 13:56:59 +00:00
---
## 8. Middleware 鏈
### 8.1 一般受保護 API
```
Request
→ go-zero JWT 驗簽
→ JwtRevokeMiddlewarejti 黑名單 + auth_gen
→ TenantContextMiddleware校驗 tenant_id 一致)
2026-05-19 17:04:26 +00:00
→ CasbinRBACMiddlewaretenant_id × role_key × path × method → Allow
2026-05-19 13:56:59 +00:00
→ handler → logic → usecase
```
### 8.2 CasbinRBACMiddleware
2026-05-19 17:04:26 +00:00
> Platform Admin bypass 在前一層 `JwtRevokeMiddleware` 第 0 步處理§4.6),此處不重複。
2026-05-19 13:56:59 +00:00
```go
// 伪代码
2026-05-19 17:04:26 +00:00
roleKeys, _ := userRoleUC.GetRoleKeys(ctx, tenantID, uid)
var hits []rbac.CheckResult
for _, roleKey := range roleKeys {
res, _ := rbacUC.Check(ctx, &rbac.CheckRequest{
2026-05-19 13:56:59 +00:00
TenantID: tenantID, UID: uid,
2026-05-19 17:04:26 +00:00
RoleKey: roleKey, Path: r.URL.Path, Method: r.Method,
2026-05-19 13:56:59 +00:00
})
2026-05-19 17:04:26 +00:00
if res.Allow {
hits = append(hits, res)
2026-05-19 13:56:59 +00:00
}
}
2026-05-19 17:04:26 +00:00
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)
2026-05-19 13:56:59 +00:00
```
### 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`,決定是否回傳明碼欄位
2026-05-19 17:04:26 +00:00
- **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>" }`
2026-05-19 13:56:59 +00:00
---
## 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
2026-05-19 17:04:26 +00:00
3. member.EnsureFromOIDC → uid如 AMEX-10000000
2026-05-19 13:56:59 +00:00
4. permission.SyncFromZitadelClaims → user_roles
2026-05-19 17:04:26 +00:00
5. auth.IssueTokenPairrole keys 快照, auth_gen
2026-05-19 13:56:59 +00:00
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
```
2026-05-19 17:04:26 +00:00
Tenant Admin → PUT /api/v1/permissions/roles/{id}/permissions
{ "permission_names": ["member.admin.list", "member.admin.read"] }
→ RolePermissionUC.Replace全量取代
2026-05-19 13:56:59 +00:00
→ PermissionTree.getFullParentPermissionIDs自動補 parent
2026-05-19 17:04:26 +00:00
→ RBACUC.LoadPolicy(tenant_id) + 廣播 reload見 §6.11
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
Tenant Admin → POST /api/v1/permissions/users/{uid}/roles
2026-05-19 13:56:59 +00:00
{ "role_id": "..." }
2026-05-19 17:04:26 +00:00
→ UserRoleUC.Assign(tenantID, uid, roleID, source=manual)
→ INCR auth_gen + DEL perm:user_roles cache
2026-05-19 13:56:59 +00:00
```
### 9.4 停權
```
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
```
---
## 10. LDAP 與 SCIM
### 10.1 三條 Provisioning 路徑
| 路徑 | 適用 | 說明 |
|------|------|------|
2026-05-19 17:04:26 +00:00
| **SCIM → ZITADEL → Gateway** | 有 HR / Entra ID / Okta | 企業推送使用者 |
2026-05-19 13:56:59 +00:00
| **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
2026-05-19 17:04:26 +00:00
- **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
2026-05-19 13:56:59 +00:00
- SCIM Groups PATCH → `permission.SyncFromScimGroup`
- SCIM deactivate → `member.suspended` + `auth.RevokeAllForUser`
2026-05-19 17:04:26 +00:00
### 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。
2026-05-19 13:56:59 +00:00
---
2026-05-19 17:04:26 +00:00
## 11. Notification Module
路徑:`internal/model/notification/`
獨立 model 模組,集中處理所有 **outbound 通訊**Email、SMS、預留Push、Webhook。所有業務模組member 業務驗證、auth step-up、tenant 系統通知、admin 警示等)**統一**透過 `NotifierUseCase` 發送,**不**直接 import provider SDK。
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
### 11.1 職責
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
- 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/ (預留)
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
**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
}
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
> **OTP 等敏感內容**`DoNotPersistBody=true` → notification.body 留空,只記 channel/kind/target hash/provider_message_id/status避免 audit DB 出現明碼 OTP。
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
### 11.4 Entity 與 Collection
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
```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
}
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
**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: {...},
}
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
### 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 萬)
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
Redis: member:seq:{tenant_id} counter初始 10000000
每個 pod 啟動或耗盡時 INCRBY 500 取一個 bucket在記憶體內遞號
UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10)
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
- **並發保護**`{ 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。
2026-05-19 13:56:59 +00:00
---
2026-05-19 17:04:26 +00:00
## 13. 資料模型與索引
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
### 13.1 Collections
2026-05-19 13:56:59 +00:00
| Collection | 模組 | 說明 |
|------------|------|------|
2026-05-19 17:04:26 +00:00
| `members` | member | Profile含業務驗證旗標、TOTP cipher、Origin |
2026-05-19 13:56:59 +00:00
| `identities` | member | zitadel_sub ↔ uid |
| `tenants` | member | 租戶 metadata |
| `tenant_ldap_configs` | member | LDAP 同步設定(加密) |
| `permissions` | permission | 全局 Permission Tree平台 seed |
2026-05-19 17:04:26 +00:00
| `roles` | permission | 租戶 Role`tenant_id` + immutable `key` |
2026-05-19 13:56:59 +00:00
| `role_permissions` | permission | Role ↔ Permission ID |
| `user_roles` | permission | uid ↔ Role支援多角色 |
2026-05-19 17:04:26 +00:00
| `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 |
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
### 13.2 主要索引
2026-05-19 13:56:59 +00:00
```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
2026-05-19 17:04:26 +00:00
{ tenant_id: 1, key: 1 } // unique
2026-05-19 13:56:59 +00:00
{ tenant_id: 1, status: 1 }
// role_permissions
2026-05-19 17:04:26 +00:00
{ tenant_id: 1, role_id: 1, permission_id: 1 } // unique
2026-05-19 13:56:59 +00:00
// 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
2026-05-19 17:04:26 +00:00
{ 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
2026-05-19 13:56:59 +00:00
```
2026-05-19 17:04:26 +00:00
> 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 萬+
2026-05-19 13:56:59 +00:00
```
Shard Key: { tenant_id: 1, uid: 1 }
```
單租戶 50 萬會集中在同一 chunkMongoDB 仍可承受;若預期單租戶千萬級再評估 hash 二次分片。
---
2026-05-19 17:04:26 +00:00
## 14. Redis Key 命名
2026-05-19 13:56:59 +00:00
### auth`internal/model/auth/redis.go`
```
auth:jwt:bl:{jti} # 單 token 黑名單TTL = 剩餘壽命
2026-05-19 17:04:26 +00:00
auth:jwt:pair:{access_jti} # access_jti → refresh_jti登出時連 refresh 一起拉黑)
2026-05-19 13:56:59 +00:00
auth:gen:{tenant_id}:{uid} # 批量失效代號
2026-05-19 17:04:26 +00:00
auth:exchange:nonce:{id_token_jti} # Token Exchange 防重放TTL 10min
auth:stepup:used:{jti} # Step-up token 單次性TTL = step_up_token TTL
2026-05-19 13:56:59 +00:00
```
### 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
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
```
### permission`internal/model/permission/redis.go`
```
2026-05-19 17:04:26 +00:00
permission:casbin:rules:{tenant_id} # Casbin policy rulesList of JSON
2026-05-19 13:56:59 +00:00
permission:tree:open # 可選open 節點 cache
perm:role_perms:{tenant_id}:{role_id} # role → permission namesTTL 30min
2026-05-19 17:04:26 +00:00
perm:user_roles:{tenant_id}:{uid} # uid → role keysTTL 5min
2026-05-19 13:56:59 +00:00
```
---
2026-05-19 17:04:26 +00:00
## 15. 規模與性能100 萬+ / 單租戶 50 萬)
2026-05-19 13:56:59 +00:00
| 項目 | 策略 |
|------|------|
| 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 起跳
```
---
2026-05-19 17:04:26 +00:00
## 16. 目錄結構
2026-05-19 13:56:59 +00:00
```
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
2026-05-19 17:04:26 +00:00
│ │ ├── 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/ # 預留
2026-05-19 13:56:59 +00:00
│ │
│ ├── model/
│ │ ├── auth/
│ │ │ └── ...
2026-05-19 17:04:26 +00:00
│ │ ├── member/ # 含 verification / step_up / totp usecase
2026-05-19 13:56:59 +00:00
│ │ │ └── ...
2026-05-19 17:04:26 +00:00
│ │ ├── 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
2026-05-19 13:56:59 +00:00
│ │ └── 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/
2026-05-19 17:04:26 +00:00
│ ├── policy_sync/ # 可選:定時 LoadPolicy
│ ├── notification_retry/ # 異步重試、DLQ 巡檢
│ └── member_anonymize/ # 軟刪 30 天後匿名化§5.7
2026-05-19 13:56:59 +00:00
├── etc/
│ ├── gateway.yaml
│ └── rbac.conf # Casbin 模型(沿用 permission-server
└── docs/
├── model.md
└── identity-member-design.md # 本文件
```
---
2026-05-19 17:04:26 +00:00
## 17. 設定檔
2026-05-19 13:56:59 +00:00
`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:
2026-05-19 17:04:26 +00:00
Issuer: https://id.internal.example.com # self-hosted 內網
2026-05-19 13:56:59 +00:00
ClientID: ${ZITADEL_CLIENT_ID}
2026-05-19 17:04:26 +00:00
JWKSUrl: https://id.internal.example.com/oauth/v2/keys
MgmtURL: https://id.internal.example.com/management/v1
2026-05-19 13:56:59 +00:00
MgmtToken: ${ZITADEL_MGMT_TOKEN}
2026-05-19 17:04:26 +00:00
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}
2026-05-19 13:56:59 +00:00
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
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
CacheTTLSeconds: 300
2026-05-19 17:04:26 +00:00
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
2026-05-19 13:56:59 +00:00
```
---
2026-05-19 17:04:26 +00:00
## 18. 實施順序
2026-05-19 13:56:59 +00:00
| 階段 | 內容 | 產出 |
|------|------|------|
2026-05-19 17:04:26 +00:00
| **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 |
2026-05-19 13:56:59 +00:00
| **P2** | JWT middleware + jti 黑名單 + auth_gen + logout/refresh | 完整 Token 生命週期 |
| **P3** | Permission seed + PermissionTree + Casbin RBAC + Redis Adapter | 可 LoadPolicy / Check |
2026-05-19 17:04:26 +00:00
| **P3.5** | Notification Module統一入口 + Email/SMS Provider+ Verification + Step-up MFA + **TOTP** | 業務驗證 + TOTP step-up + 高風險守門 |
2026-05-19 13:56:59 +00:00
| **P4** | member profile API + 預設 Role seed + CasbinRBACMiddleware | `/members/me` + API 授權生效 |
| **P5** | RolePermission + UserRole + B2B Role CRUD + Permission 勾選 API | 租戶完全自定義 |
| **P6** | Tenant 建立 + ZITADEL CreateOrg + LDAP 設定 | 多租戶 |
2026-05-19 17:04:26 +00:00
| **P7** | Directory Sync WorkerAD + OpenLDAP+ §10.4 guardrail | 企業目錄同步(誤判保護完備) |
2026-05-19 13:56:59 +00:00
| **P8** | SCIM 2.0 endpoint + Group 映射 | 企業 provisioning |
2026-05-19 17:04:26 +00:00
| **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 |
2026-05-19 13:56:59 +00:00
---
2026-05-19 17:04:26 +00:00
## 20. Audit Log 與 Rate Limit
2026-05-19 13:56:59 +00:00
2026-05-19 17:04:26 +00:00
### 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 程式碼規範)
實作時兩份文件搭配使用。
2026-05-19 13:56:59 +00:00
---
2026-05-19 17:04:26 +00:00
## 附錄 BServiceContext 組裝草案
2026-05-19 13:56:59 +00:00
```go
type ServiceContext struct {
Config config.Config
Validator validate.Validate
2026-05-19 17:04:26 +00:00
// library clients純 IO純粹封裝外部 SDK
Zitadel *zitadel.Client
EmailSender libemail.Sender
SMSSender libsms.Sender
SecretCipher libcrypto.Cipher // TOTP secret 加解密
TOTPGen libtotp.Generator
2026-05-19 13:56:59 +00:00
// usecases
AuthUC authusecase.TokenUseCase
2026-05-19 17:04:26 +00:00
StepUpTokenUC authusecase.StepUpTokenUseCase
2026-05-19 13:56:59 +00:00
MemberProvUC memberusecase.ProvisioningUseCase
MemberProfileUC memberusecase.ProfileUseCase
MemberAdminUC memberusecase.AdminUseCase
2026-05-19 17:04:26 +00:00
VerificationUC memberusecase.VerificationUseCase
StepUpUC memberusecase.StepUpUseCase
TOTPUC memberusecase.TOTPUseCase
2026-05-19 13:56:59 +00:00
TenantUC memberusecase.TenantUseCase
ScimUC memberusecase.ScimUseCase
2026-05-19 17:04:26 +00:00
// notification module
NotifierUC notifusecase.NotifierUseCase
2026-05-19 13:56:59 +00:00
// 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 遷移對照(程式碼級)
2026-05-19 17:04:26 +00:00
2026-05-19 13:56:59 +00:00
| permission-server 檔案 | Gateway 目標 | 遷移方式 |
|------------------------|--------------|----------|
| `pkg/usecase/permission_tree.go` | `model/permission/usecase/permission_tree.go` | 幾乎原樣搬移 |
2026-05-19 17:04:26 +00:00
| `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 |
2026-05-19 13:56:59 +00:00
| `pkg/domain/rbac/rule.go` | `model/permission/rbac/rule.go` | 原樣搬移 |
2026-05-19 17:04:26 +00:00
| `etc/rbac.conf` | `etc/rbac.conf` | 加入 tenant request / policy 維度 |
2026-05-19 13:56:59 +00:00
| `pkg/usecase/role.go` | `model/permission/usecase/role.go` | `ClientID`→`TenantID` |
2026-05-19 17:04:26 +00:00
| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | 加 `tenant_id` 防呆與查詢維度 |
2026-05-19 13:56:59 +00:00
| `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 17:04:26 +00:00
| 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 |