107 KiB
Identity / Member / Permission 模組設計草稿
狀態:Draft(待 Review)
適用專案:Portal API Gateway(PGW)
參考實作: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。
目錄
- 設計目標與原則
- 模組全景
- 外部系統分工
- auth 模組
- member 模組
- permission 模組(B2B 自定義)
- API 規劃
- Middleware 鏈
- 核心流程
- LDAP 與 SCIM
- Notification Module
- 可讀 UID 設計
- 資料模型與索引
- Redis Key 命名
- 規模與性能(100 萬+ / 單租戶 50 萬)
- 目錄結構
- 設定檔
- 實施順序
- 已決策事項
- 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 核心原則
-
職責分離
auth:你是誰(Authentication)member:你的業務資料是什麼(Profile)permission:你能做什麼(Authorization)
-
LDAP 不做登入 bind
- 登入驗證由 ZITADEL LDAP IdP 處理
- Gateway 的 LDAP client 僅供 Directory Sync(read-only)
-
Token Exchange
- 對外 API 只接受 Gateway 簽發的 CloudEP JWT
- ZITADEL OIDC token 僅在
/auth/token/exchange使用一次
-
租戶隔離
- 所有持久化資料以
tenant_id為邊界 - JWT
tenant_id與請求資源必須一致
- 所有持久化資料以
-
B2B 權限自定義(參考 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(已決策)
- 平台 seed 全局 Permission Tree(含
-
身份驗證 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 介面
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
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(已決策)
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 受保護路由:
@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 層守門:
- 解 step_up JWT → 檢
typ == "step_up"、tenant_id、uid、action == expected SETNX auth:stepup:used:{jti}=1,已存在 → 視為重放,拒絕- 通過後執行高風險操作;token 即作廢
- 解 step_up JWT → 檢
- 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 拼裝;本層只負責單一動作 + 副作用。
介面分兩層:
- Atomic primitives:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification)。Logic 可任意組合,跨流程共用。
- Composite:把幾個常用 atomic 預先組好的「快捷組合」(如
VerificationUseCase=OTP.Generate+Notifier.Send+Member.SetVerified)。Composite 是可選,logic 也可以繞過直接組 atomic。業務邏輯(API、handler、流程編排)目前不實作;先固化介面契約。
5.2.1 Atomic primitives
// ──────────────────────────────────────────────────────────
// 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(特殊需求)。
// 業務 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 草案
// 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 草案
// 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 業務級驗證模型(已決策)
// 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 Syncscim: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):
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)。
// 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 即時、不易被攔截 |
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)
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)
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 驗證(未來路徑)
// 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
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
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
m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{
TenantID: tenantID, ExternalID: scimExternalID,
UserName: username, Email: email, Active: true, RawPayload: rawJSON,
})
Case E:已登入 user 改綁業務 email(atomic 直組 vs composite)
// 路徑 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 已驗證的設計:Casbin + Redis RBAC、Permission Tree(父子繼承)、HTTP Path/Method 綁定。
與舊 permission-server 的差異:Token 簽發/驗證/黑名單移至 Gatewayauth模組;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。
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)
filterOpenNodes:父節點status=close→ 整棵子樹不可用getFullParentPermissionIDs:勾選子權限 → 自動加入所有父節點 IDgetFullParentPermission:查 Role 權限 → 回傳含父節點的完整 permission name → status map(供前端 UI)
6.5 Role Entity(B2B 租戶自定義)
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 自定義規則
- 租戶可 CRUD 自訂 Role(
is_system=false) - 系統 seed 的預設 Role(
is_system=true)可修改 Permission 集合,tenant_owner 不可刪 - Role 綁定的 Permission 必須是全局 Catalog 中
status=open的節點 - 租戶不可勾選
system.*權限(除非平台另行開啟) - 至少保留一個 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
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 多租戶版)
[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/*萬用 pathregexMatch:支援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)
// 輸入: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 介面
// --- 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 映射
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
- Writer:每次
- 兜底:每 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}= ZITADELorg_id,與 JWTtenant_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),此處不重複。
// ?代?
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.selectvs 查他人:若 path 含:uid且 uid ≠ caller,需命中member.admin.readPlainCode:Logic 讀ctx.plain_code,決定是否回傳明碼欄位- Step-up 守門(高風險 action):
- 從 Header
X-Step-Up-Token取 token auth.StepUpTokenUseCase.Verify(token, expectedAction, tenantID, uid)- 檢
typ == "step_up"、action == expectedAction、tenant_id/uid與 ctx 一致、未過期
- 檢
SETNX auth:stepup:used:{jti}=1,已存在 →403 step_up_replay- 全部通過 → 執行業務操作
- 失敗 →
403 step_up_required+{ required_action: "<action>" }
- 從 Header
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)
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不對外曝露;ZITADELsub透過 SCIM Extension Schemaurn: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 介面
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
// 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):
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_dlqcollection,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 |
異步 | |
tenant.UseCase(租戶建立完成) |
tenant_welcome |
異步 | |
| 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)→ fallbackGetByZitadelUserID或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 主要索引
// 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 映射以
identitiescollection 為 source of truth;members.zitadel_user_id若保留,只作反查快取/denormalized 欄位,更新需由同一 transaction 或補償流程維持一致。
時間欄位:
CreateAt/UpdateAt統一為 epoch milliseconds(UTC)。對外 SCIMmeta.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 擴充草案:
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 互拖)。
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:怎麼寫(entity / repository / usecase 程式碼規範)
實作時兩份文件搭配使用。
附錄 B:ServiceContext 組裝草案
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 |