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