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

107 KiB
Raw Blame History

Identity / Member / Permission 模組設計草稿

狀態Draft待 Review
適用專案Portal API GatewayPGW
參考實作app-cloudep-permission-serverCasbin RBAC、Permission Tree、Role/RolePermission
最後更新2026-05-19
前提:全新 Gateway module不考慮舊版 member-server 遷移。

本文件描述 Gateway 內 authmemberpermission 三個業務模組的目標架構,整合 ZITADEL(身份)、LDAP(企業目錄)、SCIM 2.0(企業 provisioning支援 多租戶百萬級會員(含單租戶 50 萬)。

模組分層與程式碼撰寫規範見 model.md


目錄

  1. 設計目標與原則
  2. 模組全景
  3. 外部系統分工
  4. auth 模組
  5. member 模組
  6. permission 模組B2B 自定義)
  7. API 規劃
  8. Middleware 鏈
  9. 核心流程
  10. LDAP 與 SCIM
  11. Notification Module
  12. 可讀 UID 設計
  13. 資料模型與索引
  14. Redis Key 命名
  15. 規模與性能100 萬+ / 單租戶 50 萬)
  16. 目錄結構
  17. 設定檔
  18. 實施順序
  19. 已決策事項
  20. Audit Log 與 Rate Limit

1. 設計目標與原則

1.1 目標

目標 說明
統一身份 ZITADEL 作為 IdP含 LDAP IdP、Social Login
業務會員 Gateway member 模組管理 tenant-scoped profile
細粒度授權 Gateway permission 模組(Casbin RBAC + Permission Tree每個 B2B 租戶可自定義 Role 並勾選 Permission
Token go-zero JWT 驗證 + Redis 黑名單(只黑名單 JWT
企業整合 SCIM 2.0 + LDAP Directory SyncAD + OpenLDAP
規模 全平台 100 萬+ 會員;單租戶可達 50 萬
UID 人類可讀、帶租戶前綴,如 AMEX-10000000;唯一性以 tenant_id + uid 為準

1.2 核心原則

  1. 職責分離

    • auth你是誰Authentication
    • member你的業務資料是什麼Profile
    • permission你能做什麼Authorization
  2. LDAP 不做登入 bind

    • 登入驗證由 ZITADEL LDAP IdP 處理
    • Gateway 的 LDAP client 僅供 Directory Syncread-only
  3. Token Exchange

    • 對外 API 只接受 Gateway 簽發的 CloudEP JWT
    • ZITADEL OIDC token 僅在 /auth/token/exchange 使用一次
  4. 租戶隔離

    • 所有持久化資料以 tenant_id 為邊界
    • JWT tenant_id 與請求資源必須一致
  5. B2B 權限自定義(參考 app-cloudep-permission-server

    • 平台 seed 全局 Permission Treehttp_path / http_method
    • 租戶建立自訂 Role從 Tree 勾選 PermissionRolePermission + 自動補 parent
    • API 授權由 Casbin 比對 (tenant_id, role_key, path, method),避免不同租戶同名角色互相污染
    • B2C 租戶唯讀 seed 模板,不可自定義 Role已決策
  6. 身份驗證 vs 業務驗證分層(已決策)

    • ZITADEL = 身份級驗證:登入 MFATOTP / WebAuthn / SMS、註冊 email 驗證、忘記密碼、帳號鎖定
    • Gateway member = 業務級驗證:業務 email / phone 綁定 OTP、Step-up MFA
    • Gateway 依賴 ZITADEL email_verified 當業務守門條件Logic 層改讀 BusinessEmailVerified 等 member 旗標
    • Email / SMS OTP 由 Gateway 自送(不轉 ZITADEL Notification
    • MFA 強制策略admin 級 role 由 ZITADEL Org Policy 強制 TOTP一般 user 預設不強制,但高風險操作走 Gateway Step-up

2. 模組全景

┌─────────────────────────────────────────────────────────────────┐
│                     Portal API Gateway (go-zero)                   │
├─────────────────────────────────────────────────────────────────┤
│  generate/api/                                                   │
│    auth.api · member.api · permission.api · tenant.api · scim.api│
├─────────────────────────────────────────────────────────────────┤
│  internal/middleware/                                            │
│    jwt_revoke · casbin_rbac · scim_auth · tenant_context           │
├─────────────────────────────────────────────────────────────────┤
│  internal/model/                                                 │
│    auth/         → Token 簽發、換票、登出、黑名單、auth_gen、step-up│
│    member/       → Profile、Identity、Tenant、UID、Sync、TOTP、驗證│
│    permission/   → Casbin RBAC、Permission Tree、RoleB2B 自定義)│
│    notification/ → Email/SMS/Push 統一發送、模板、重試、audit     │
├─────────────────────────────────────────────────────────────────┤
│  internal/library/                                               │
│    zitadel/ · ldap/ · uid/ · casbin/                              │
│    notification/email · notification/sms · notification/push     │
├─────────────────────────────────────────────────────────────────┤
│  internal/worker/                                                │
│    directory_sync/ · notification_retry/ · member_anonymize/     │
└─────────────────────────────────────────────────────────────────┘
         │                    │                    │
         ▼                    ▼                    ▼
     MongoDB               Redis              ZITADEL
   (profile/role)      (cache/blacklist)    (identity/LDAP IdP)
                            +
                     Email / SMS Provider

2.1 模組依賴方向

handler → logic → model/{auth|member|permission|notification}/usecaseinterface
                      ↓
                repository → MongoDB / Redis

logic 不 import entity / repository見 model.md

auth          → memberEnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM
auth          → permissionSyncRolesFromClaims
auth          → member.TOTPUseCasestep-up TOTP 驗證)
member        → auth停權時 RevokeAllForUser
member        → notification業務驗證 / step-up OTP 寄送)
permission    → member可選驗證 uid 存在)
notification  → library/notification/{email,sms,push}provider 實作)

3. 外部系統分工

能力 ZITADEL Gateway auth Gateway member Gateway permission Gateway notification
註冊 / 登入OIDC / LDAP / SCIM ? 換票 EnsureFromOIDC/LDAP/SCIM SyncRoles
平台原生註冊(未來,含 email OTP local user LifecycleUseCase + OTPUseCase 寄 OTP
密碼 / 身份 MFA / 忘記密碼 ?
身份 MFA 強制策略 ? Org Policy
Google / LINE / Apple ? IdP
LDAP 登入 ? LDAP IdP Group→Role 映射
Access / Refresh Token對外 ? CloudEP JWT
Step-up Token高風險操作 ? 簽 step_up_token OTP / TOTP 驗證 Logic 守門 OTP 寄送
業務 TOTPAuthenticator ? secret 加密儲存 + 驗證
JWT 黑名單 ? Redis
業務 UID ?
Profile ?
業務 Email / Phone 驗證 ? Verification 流程 ? OTP 寄送
Email / SMS / Push 發送 ? 統一入口 + 模板 + 重試
會員列表 / 狀態 ? 需授權 變更通知(異步)
API 細粒度權限 粗粒度 Role Casbin RBACpath + 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 模組產生 業務會員 IDAMEX-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 / JWKSGateway 透過內網 URL 存取,不經公網
  • 設定etc/gateway.yamlZitadel.Issuer / MgmtURL 指向 self-hosted 端點

3.4 註冊路徑(已決策:不提供 Gateway 註冊 API

Gateway 不暴露 /auth/register。註冊由下列路徑完成:

租戶類型 註冊路徑 首次登入副作用
B2C ZITADEL Hosted Register UI或前端走 ZITADEL OIDC PKCE token exchange 觸發 EnsureFromOIDC JIT
B2BLDAP 由 IT 在 AD / OpenLDAP 建帳;可選 Directory Sync 預 provision 到 ZITADEL LDAP IdP 登入觸發 EnsureFromLDAP JIT
B2BSCIM HR / Okta / Entra 推 SCIM Create User SCIM endpoint 寫 ZITADEL + Gateway不需 JIT

ZITADEL 內建 email 驗證已完成「可登入」門檻;業務上「可使用功能」門檻見 §5.4 業務驗證。

3.5 平台 MFA 強制(已決策)

  • ZITADEL Org Policy 設定:任何 admin 級 roletenant_owner / tenant_admin / platform_super_admin)登入時強制 TOTP / WebAuthn
  • 一般 user 預設不強制(避免 B2C 流失)
  • 高風險業務操作 → 走 Gateway Step-up MFA§5.6),與 ZITADEL 身份 MFA 互不取代

4. auth 模組

路徑:internal/model/auth/

4.1 職責

  • 驗證 ZITADEL OIDC tokenid_token / authorization_code + PKCE
  • 編排 member.EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIMpermission.SyncRolesFromClaims
  • 簽發 CloudEP JWTaccess + 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_jwttyp=refresh、未過期、auth_gen 有效)
  2. 若 refresh_jti 已在黑名單:
        視為被竊或重放 → INCR auth:gen:{tenant_id}:{uid}(撤銷整條 chain
        回 401並寫 audit log
  3. 簽發新 access_jwt + 新 refresh_jwt新 jti
  4. 黑舊 refresh_jti若舊 access 對應 jti 仍未過期,一併黑名單
  5. 寫入新的 auth:jwt:pair
  • 每次 refresh 都輪換Refresh Token Rotation
  • Reuse detection:舊 refresh 被第二次使用 → 視同盜用,立即批量撤銷該 user

Token Exchange 防重放

POST /auth/token/exchange { tenant_slug, id_token }
  1. zitadel.VerifyIDToken檢 aud、iss、exp、signature
  2. 強制檢查 id_token.iat 在最近 5 分鐘內
  3. SETNX auth:exchange:nonce:{id_token.jti}=1 TTL 10min失敗 → 409 已使用
  4. 校驗 tenant_slug → tenant.org_id == id_token.org_id
  5. EnsureFromOIDC / SyncRoles / IssueTokenPair

Step-up Token單次性、鎖 action

Key:   auth:stepup:used:{jti}    SETNX TTL = step_up_token TTL
Value: 1
  • TTL5 分鐘
  • Claimstyp=step_up + action(如 change_business_email
  • Logic 層守門:
    1. 解 step_up JWT → 檢 typ == "step_up"tenant_iduidaction == expected
    2. SETNX auth:stepup:used:{jti}=1,已存在 → 視為重放,拒絕
    3. 通過後執行高風險操作token 即作廢
  • Step-up token 進 jti 黑名單系統;單次性靠 auth:stepup:used 即可

批量失效(停權 / 改密碼 / SCIM deactivate / 權限變更

Key:   auth:gen:{tenant_id}:{uid}
Value: 整數,預設 1事件發生時 INCR

Middleware 檢查:token.auth_gen >= redis.auth_gen,否則 401。

已決策UserRole 指派/撤銷、外部 Group 映射導致的 user role 變更 → INCR auth_gen(等效強制刷新,使用者需重新 exchange/refresh 取得新 auth_gen

RolePermission 變更不改變「使用者有哪些角色」,只需 LoadPolicy(tenant_id) + 權限快取失效;若未來改成完全信任 JWT 內角色/權限快照,才需要同步 INCR auth_gen

JWT 內不放全部 permission避免 token 過大);批量失效用 auth_gen,單次登出用 jti 黑名單。

4.6 Middleware 檢查順序

0. Platform Admin allowlist 命中platform tenant + platform_super_admin role 或 break-glass UID
   → audit.LogPlatformBypass → 直接放行
1. go-zero JWT 驗簽 + exp
2. typ == "access"(受保護 API
3. NOT EXISTS auth:jwt:bl:{jti}
4. claims.auth_gen >= redis auth:gen:{tenant}:{uid}
   - redis key 不存在 → 視為 0
   - 簽發 token 時 claims.auth_gen = redis.GET 或 0
5. 注入 contexttenant_id, uidrole keys 由下一層 CasbinRBACMiddleware 從 cache 載入)

5. member 模組

路徑:internal/model/member/

5.1 職責

  • 會員 Profile CRUDtenant-scoped
  • Identity 映射(zitadel_sub ? uid
  • Tenant metadata 與 LDAP 同步設定
  • UID 產生(可讀格式)
  • SCIM 業務寫入SCIM id / Gateway UID + 客戶端 externalId
  • Directory Sync WorkerAD + OpenLDAP
  • 會員狀態active / suspended / deleted→ 通知 auth 撤銷 token
  • 業務級驗證business email / phone 綁定 + OTP 自送
  • Step-up MFA OTP 驗證(搭配 auth 模組簽 step_up_token

5.2 UseCase 介面

設計原則(呼應 model.md:每個 UseCase 是原子業務操作不假設前後步驟存在。流程編排(如「註冊 → 寄驗證信 → 啟用」)由 logic 層用多個 UseCase 拼裝;本層只負責單一動作 + 副作用。

介面分兩層:

  1. Atomic primitives:純粹的單一動作(建 member、產 OTP、驗 OTP、寄 notification。Logic 可任意組合,跨流程共用。
  2. Composite:把幾個常用 atomic 預先組好的「快捷組合」(如 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 → activecaller 須先確保所有前置驗證已通過
    Activate(ctx context.Context, tenantID, uid string) error
    // 停權active → suspended不撤 token撤 token 由 auth 模組做)
    Suspend(ctx context.Context, tenantID, uid, reason string) error
    // 復權suspended → active
    Reactivate(ctx context.Context, tenantID, uid string) error
    // 軟刪active|suspended → deleted不會立刻匿名化30 天後由 worker 處理 §5.7
    SoftDelete(ctx context.Context, tenantID, uid string) error
    // 中止未啟用註冊(逾時清理;只能對 unverified 用)
    AbortPending(ctx context.Context, tenantID, uid string) error
}

// ──────────────────────────────────────────────────────────
// Provisioning外部來源 → Gateway member 的 JIT / sync upsert
//   每個來源獨立一個動作email 視為來源 IdP 已驗證,不再走 OTP
// ──────────────────────────────────────────────────────────
type ProvisioningUseCase interface {
    // ZITADEL OIDC token exchange用 id_token claims 上 upsertB2C / Social IdP
    EnsureFromOIDC(ctx context.Context, req *EnsureFromOIDCRequest) (*MemberDTO, error)
    // ZITADEL LDAP IdP 登入後 JIT或 Directory Sync worker 推送
    EnsureFromLDAP(ctx context.Context, req *EnsureFromLDAPRequest) (*MemberDTO, error)
    // SCIM Create / Update User
    EnsureFromSCIM(ctx context.Context, req *EnsureFromSCIMRequest) (*MemberDTO, error)
}

// ──────────────────────────────────────────────────────────
// OTPatomic、purpose-agnostic 一次性密碼
//   不寄信、不更新 membercaller 拿 code 後自行透過 NotifierUseCase 投遞
// ──────────────────────────────────────────────────────────
type OTPUseCase interface {
    // 生成bcrypt 存 redis回 challenge_id + 明碼 code一次性回傳
    Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, error)
    // 驗證:成功則 invalidatepurpose 必須與 challenge 建立時一致
    Verify(ctx context.Context, req *VerifyOTPRequest) error
    // 主動失效(換 challenge / 取消註冊)
    Invalidate(ctx context.Context, tenantID, challengeID string) error
}

// ──────────────────────────────────────────────────────────
// TOTPAuthenticator App見 §5.8
// ──────────────────────────────────────────────────────────
type TOTPUseCase interface {
    StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error)
    ConfirmEnroll(ctx context.Context, tenantID, uid, code string) (backupCodes []string, err error)
    VerifyCode(ctx context.Context, tenantID, uid, code string) error
    Disable(ctx context.Context, tenantID, uid string) error
    RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error)
}

// ──────────────────────────────────────────────────────────
// Tenant
// ──────────────────────────────────────────────────────────
type TenantUseCase interface {
    Create(ctx context.Context, req *CreateTenantRequest) (*TenantDTO, error)
    ResolveBySlug(ctx context.Context, slug string) (*TenantDTO, error)
    ConfigureLDAP(ctx context.Context, req *ConfigureLDAPRequest) error
}

// ──────────────────────────────────────────────────────────
// SCIM Resource handlers
// ──────────────────────────────────────────────────────────
type ScimUseCase interface {
    CreateUser(ctx context.Context, req *ScimCreateUserRequest) (*ScimUserDTO, error)
    GetUser(ctx context.Context, req *ScimGetUserRequest) (*ScimUserDTO, error)
    PatchUser(ctx context.Context, req *ScimPatchUserRequest) (*ScimUserDTO, error)
    DeleteUser(ctx context.Context, req *ScimDeleteUserRequest) error
    PatchGroup(ctx context.Context, req *ScimPatchGroupRequest) error
}

type DirectorySyncUseCase interface {
    SyncTenant(ctx context.Context, tenantID string) (*SyncResult, error)
}

5.2.2 Composite可選常用組合的便利包

Composite 內部只呼叫 Atomic primitives + library / notifier不持有任何不可由 atomic 推出的副作用
Logic 可選擇用 composite簡單情況或直接組 atomic特殊需求

// 業務 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 claimOIDC 通常 true
    DisplayName   string
    Locale        string
    RawClaims     map[string]any
}

type EnsureFromLDAPRequest struct {
    TenantID    string
    ExternalID  string      // objectGUID / entryUUID
    LDAPDN      string
    Username    string
    Email       string
    DisplayName string
    Groups      []string
    Source      enum.RoleSource // ldap_sync | ldap_jit
}

type EnsureFromSCIMRequest struct {
    TenantID    string
    ExternalID  string      // SCIM externalId不等於 UID
    UserName    string
    Email       string
    DisplayName string
    Active      bool
    RawPayload  map[string]any
}

// Platform registration
type CreatePlatformMemberRequest struct {
    TenantID    string
    Email       string
    PasswordHash string             // 若使用 ZITADEL local user留空由 ZITADEL 管)
    DisplayName string
    Language    string
    // 不會立即 active新建 member.status = unverified
}

// OTP
type GenerateOTPRequest struct {
    TenantID   string
    Purpose    enum.OTPPurpose      // registration_email | business_email | business_phone | step_up | password_reset | ...
    Identifier string               // 通常是 uid註冊期 uid 尚未存在時可用 hash(email)
    Length     int                  // 0 = 用 config 預設6
    TTLSeconds int                  // 0 = 用 config 預設300
}

type OTPChallengeDTO struct {
    ChallengeID string
    Code        string               // 僅 Generate 時回傳一次明碼caller 自負投遞
    ExpiresIn   int
}

type VerifyOTPRequest struct {
    TenantID    string
    ChallengeID string
    Code        string
    Purpose     enum.OTPPurpose      // 必填,防 challenge 被借用到其他用途
}

// Step-up
type StepUpStartRequest struct {
    TenantID       string
    UID            string
    Action         enum.StepUpAction
    PreferChannel  enum.Channel      // 可選totp | sms | email不指定則依 §5.6 優先序
}

type StepUpChallengeDTO struct {
    ChallengeID string                // TOTP 無 challenge_id 也可回固定值Confirm 時不會去比對
    Channel     enum.Channel
    ExpiresIn   int
}

type StepUpConfirmRequest struct {
    TenantID    string
    UID         string
    ChallengeID string
    Code        string
    Action      enum.StepUpAction
}

5.2.4 Enum 草案

// 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.RevokeAllForUserINCR auth_gen
deleted 軟刪除 清 cache、撤銷 token、ZITADEL disable30 天後匿名化§5.7

來自 OIDC / LDAP / SCIM 的 member 直接建為 activeemail 由來源 IdP 已驗證);只有 platform-native 註冊會經過 unverified
業務 email / phone 驗證以獨立旗標(BusinessEmailVerified / BusinessPhoneVerified)表示,與生命週期狀態解耦。

Member 欄位 Source of Truth已決策

欄位類別 範例 SoT 行為
身份識別 zitadel_subZitadelEmailDisplayNameIdP、ZITADEL status ZITADEL 每次 token exchange / webhook 同步Gateway 不可改寫
業務資料 BusinessEmail/Phone(+Verified)LanguageCurrencyAvatarPreferences Gateway 業務 API 寫;不回推 ZITADEL
Provisioning 來源 external_idldap_dn、SCIM 群組成員資格 來源系統LDAP/SCIM sync replaceGateway 不直接編輯

推論:Member.Origin 標主來源對應「Provisioning」欄位類別的可寫範圍。Gateway UI 改業務欄位永遠可行;改身份/Provisioning 欄位需走來源系統。

5.4 業務級驗證模型(已決策)

// Member 既有 + 本節新增欄位
type Member struct {
    TenantID                string
    UID                     string
    ZitadelUserID           string  // ZITADEL subOIDC / LDAP IdP / platform local user 都會有)
    ZitadelEmail            string  // 來源 IdP 提供的登入 email
    DisplayName             string
    Avatar                  string
    Phone                   string
    Language                string
    Currency                string

    Status                  enum.MemberStatus // unverified | active | suspended | deleted
    Origin                  enum.MemberOrigin // platform_native | oidc | ldap | scim
    PasswordHash            string  // 平台原生且不用 ZITADEL local user 時才填;其餘留空

    BusinessEmail           string  // 業務 email可與 ZitadelEmail 不同)
    BusinessEmailVerified   bool
    BusinessEmailVerifiedAt int64
    BusinessPhone           string
    BusinessPhoneVerified   bool
    BusinessPhoneVerifiedAt int64

    TOTPEnrolled            bool
    TOTPSecretCipher        string
    TOTPEnrolledAt          int64
    TOTPBackupCodesHash     []string

    CreateAt                int64
    UpdateAt                int64
    DeletedAt               int64   // soft delete 時間
    AnonymizedAt            int64   // 匿名化時間
}

Origin 取值:

  • platform_nativeGateway 平台原生註冊(搭配 ZITADEL local user 或 Gateway 自管密碼)
  • oidcSocial / ZITADEL Hosted UI 等 IdP 來的
  • ldap:透過 ZITADEL LDAP IdP 或 Directory Sync
  • scimHR / Entra / Okta 推送

Member.Origin 決定 Profile 欄位 UI 可寫範圍:

  • zitadel_local身份欄位IdP email/name唯讀需走 ZITADEL UI 改;業務欄位可寫
  • ldap:身份 + provisioning 欄位皆唯讀(由 Directory Sync 維護);業務欄位可寫
  • scim:身份 + provisioning 欄位由 SCIM Provider 推送,唯讀;業務欄位可寫

UserRole.Source 仍維持 manual / zitadel / ldap / scim,影響 sync replace 範圍(見 §6.10)。兩者各司其職。

欄位 來源 用途
ZitadelEmail(既有) OIDC claim 登入帳號識別,不做業務守門
BusinessEmail 業務 API 綁定 + OTP 業務通知、業務守門條件
BusinessPhone 業務 API 綁定 + OTP SMS 通知、Step-up MFA 通道

Verification Challenge不入 Mongo僅存 RedisTTL 5min

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, &notification.SendRequest{
    TenantID:         tenantID,
    UID:              uid,
    Channel:          enum.ChannelEmail,
    Kind:             enum.NotifyVerifyEmail,
    Target:           targetEmail,
    Locale:           member.Language,
    Data:             map[string]any{"code": otp, "expires_in": 300},
    IdempotencyKey:   challengeID,           // 同 challenge 不會重發
    DoNotPersistBody: true,                  // OTP 不入 notification.body
    Severity:         enum.SeverityInfo,
})
  • OTP 規格6 位數、TTL 5min、bcrypt 儲存(不存明碼)、重發冷卻 60s、單一 challenge 失敗 5 次直接鎖
  • Rate Limit
    • verify:rate:{tenant}:{uid}:{kind} SETNX TTL=60s重發保護
    • verify:daily:{tenant}:{uid}:{kind} INCR TTL=24h單日上限預設 10 次)
  • AuditStart / Confirm 進 audit logNotification 自己也會記送達狀態,兩者互補)
  • Provider 切換不影響 member 模組:換 SendGrid → SES、Twilio → SNS 只動 etc/gateway.yaml 與 library 實作

5.6 Step-up MFA已決策啟用

用途:高風險業務操作前的二次驗證,與 ZITADEL 身份 MFA 互不取代

高風險 Action 清單enum

Action 目標 API
change_business_email PATCH /members/me/business-email
change_business_phone PATCH /members/me/business-phone
delete_member DELETE /members/me
tenant_admin_force_status PATCH /members/:uid/status(管理員停權他人)
revoke_all_sessions POST /auth/revoke-all
disable_totp DELETE /members/me/totp

後續可由 tenant 透過設定加白名單;初版 platform-wide enum禁止任意字串。

Step-up 通道(已決策)

優先序:TOTP > SMS > Email

通道 條件 為何優先
TOTPGoogle Authenticator 使用者已 enroll_totp 完成§5.8 不依賴外部 provider、不會被 SIM swap、無頻寬限制、零成本
SMS BusinessPhoneVerified = true 比 email 即時、不易被攔截
Email BusinessEmailVerified = true 後備通道

Start 時由 StepUpUseCase 依使用者狀態挑通道;若使用者要求其他通道(如不想用 TOTP可在 request 帶 prefer_channel 覆寫,但仍需該通道已驗證。

流程

1. Client → POST /auth/step-up/start { action, prefer_channel?: "totp" }
   - 解析使用者已可用通道;挑選優先通道
   - 若選 totp不寄 OTP直接回 challenge_idcode 由使用者從 app 取
   - 若選 sms/email生成 6 碼 OTP、bcrypt 儲存、透過 NotifierUseCase.Send 寄出
   ← { challenge_id, channel: "totp"|"sms"|"email", expires_in: 300 }

2. Client → POST /auth/step-up/confirm { challenge_id, code, action }
   - totpmember.TOTPUseCase.VerifyCode(uid, code, window=±1)
   - sms/emailbcrypt 比對 challenge code失敗 INCR AttemptCnt
   - 成功 → auth.StepUpTokenUseCase.Issue(tenant, uid, action) → 短壽 JWT
   ← { step_up_token, token_type: "step_up", expires_in: 300 }

3. Client → PATCH /members/me/business-email { ... }
       Header: X-Step-Up-Token: <step_up_token>
   - Logic 層:
     a. Casbin enforce 通過(基本權限)
     b. StepUpTokenUseCase.Verify(token, expectedAction="change_business_email", tenant, uid)
     c. SETNX auth:stepup:used:{jti}=1已用過 → 拒絕
     d. 執行業務邏輯

守門點

  • Logic 層守門:Casbin allow 後再驗 step-up雙閘門
  • Header 名稱:X-Step-Up-Token
  • 失敗回傳:403 step_up_required + { required_action: "change_business_email", available_channels: ["totp","sms"] },前端依此跳 step-up 流程

5.7 帳號刪除與匿名化(已決策)

T0: DELETE /api/v1/members/me   (Step-up: delete_member)
    1. status = deleted, deleted_at = now
    2. auth.RevokeAllForUserINCR auth_gen + 拉 jti pair 黑名單)
    3. ZITADEL Mgmt.DeactivateUser
    4. 清 member:profile / member:sub cache
    5. audit log (actor, ip, ua, step_up_jti)

T+30 天: cron `member_anonymize_worker`
    匿名化欄位(覆寫為 hash 或固定 placeholder:
      ZitadelEmail        → "deleted:{uid}@anonymized.local"
      DisplayName         → "Deleted User"
      Avatar              → ""
      Phone               → ""
      BusinessEmail       → ""
      BusinessPhone       → ""
      BusinessEmail/PhoneVerified → false
      TOTPSecretCipher    → ""
      TOTPBackupCodesHash → nil
      external_id, ldap_dn → ""
      zitadel_sub         → "deleted:{uid}"   # 維持 identities 唯一索引
    保留欄位(不可改 / 審計用):
      tenant_id, uid, status=deleted, deleted_at, anonymized_at, created_at
    寫 audit log: action=member.anonymized
  • 不可逆30 天內可由租戶 admin 還原(status=deleted → active,恢復 cache但 ZITADEL 帳號需另行啟用)
  • audit log 不受匿名化影響actor uid 仍保留,便於追溯)
  • 匿名化後 SCIM Users.{id} 仍可查到(回傳 active=false + 匿名 payload不回 404以維持 client 的 reconciliation

5.8 TOTPAuthenticator App已決策啟用

業務級 TOTPGateway 自己存 secret,與 ZITADEL 身份級 TOTP 獨立(兩個獨立綁定,使用者首次 setup 需各掃一次 QR

為什麼分開ZITADEL TOTP 是登入用、secret 在 ZITADELGateway step-up TOTP 用於業務操作、secret 在 Gateway避免 Gateway 對 ZITADEL 私有資料的依賴與耦合。

Member 欄位(補充 §5.4

type Member struct {
    // ... 既有欄位
    TOTPEnrolled       bool
    TOTPSecretCipher   string   // AES-GCM(secret, KEK)AES-256KEK 走 KMS / secret manager
    TOTPEnrolledAt     int64
    TOTPBackupCodesHash []string // bcrypt(code)10 組一次性備援碼,用過即抹除
}

Secret 必須對稱加密儲存,禁止明碼或單純 base32。KEK 走 KMS / Vaultrotation 時逐筆 re-encrypt背景 worker

UseCase 介面(補充 §5.2

type TOTPUseCase interface {
    // 產生 secret + otpauth URL + 10 組 backup codes首次 enroll尚未啟用
    StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error)
    // 使用者掃 QR、輸入第一組 code → 確認 → 標 TOTPEnrolled = true
    ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) // 回 backup_codes明碼只回一次
    // step-up 用:驗一個 code含 backup code
    VerifyCode(ctx context.Context, tenantID, uid, code string) error
    // 解除綁定(需 step-up = disable_totp
    Disable(ctx context.Context, tenantID, uid string) error
    // 重新產生 backup codes需 step-up
    RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error)
}

流程

A. Enroll
   Client → POST /api/v1/members/me/totp/enroll-start
      1. 若已 TOTPEnrolled = true → 409 already_enrolled
      2. 生成 32-byte random secret → base32
      3. otpauth_url = "otpauth://totp/{Issuer}:{tenant_slug}:{uid}?secret={base32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30"
      4. 暫存於 Redis不入 Mongo避免半完成的 secret 散落):
           totp:enroll:{tenant}:{uid} = {secret_cipher} TTL 10min
      ← { otpauth_url, qr_png_base64 }

   Client → POST /api/v1/members/me/totp/enroll-confirm { code }
      1. 從 Redis 取暫存 secret
      2. VerifyTOTP(secret, code, window=±1) → 失敗則 400 invalid_code
      3. member.TOTPSecretCipher = secret_cipher
         member.TOTPEnrolled = true
         member.TOTPEnrolledAt = now
      4. 生成 10 組 backup code (random hex)、bcrypt 後存 TOTPBackupCodesHash
      5. DEL totp:enroll:*
      6. audit log
      ← { backup_codes: [...10 組明碼,僅此一次回傳] }

B. Verifystep-up 共用)
   StepUpUseCase.Confirm 內:
      VerifyTOTP(decryptedSecret, code, window=±1) OR matchBackupCode(code)
      若用 backup code 命中 → 從 TOTPBackupCodesHash 移除該筆(單次性)

C. Disable
   Client → DELETE /api/v1/members/me/totp
      Header: X-Step-Up-Token: <action=disable_totp>
      1. 清 TOTPSecretCipher、TOTPEnrolled=false、TOTPBackupCodesHash=nil
      2. audit log

TOTP 演算法與參數

  • RFC 6238SHA1 / 30s period / 6 digits相容 Google Authenticator、Authy、1Password、Microsoft Authenticator
  • window = ±1:允許前後一個 30s 區間,容忍時鐘漂移
  • Replay 保護:成功使用的 (uid, code, timestep) 寫入 totp:used:{tenant}:{uid}:{timestep} SETNX TTL=90s同一 code 二次出現直接拒
  • Backup code10 組、12 字 hex48-bit entropy、bcrypt cost 10、明碼僅 enroll 時回傳一次

API補充 §7.2

Method Path 說明 Step-up
POST /api/v1/members/me/totp/enroll-start 取 otpauth URL + QR
POST /api/v1/members/me/totp/enroll-confirm 驗第一組 code啟用 + 回 backup codes
GET /api/v1/members/me/totp 取 TOTP 狀態enrolled? backup 剩餘數)
POST /api/v1/members/me/totp/backup-codes 重產 backup codes ? disable_totp
DELETE /api/v1/members/me/totp 解除綁定 ? disable_totp

5.9 UseCase 編排示例純概念handler / API 暫不實作)

展示 atomic primitives 可任意組合的邏輯流。logic 層尚未實作;本節僅證明介面契約可支撐預期業務。

Case A平台原生註冊 + Email OTP 驗證(未來路徑)

// 1) 建立 unverified member不寄信、不發 token
m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{
    TenantID: tenantID, Email: email, DisplayName: name,
})

// 2) 產生 OTPatomic、purpose-agnostic
chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{
    TenantID:   tenantID,
    Purpose:    OTPPurposeRegistrationEmail,
    Identifier: m.UID,
})

// 3) 投遞 OTPatomiccaller 控制 channel / template
notifier.Send(ctx, &SendRequest{
    TenantID:         tenantID,
    UID:              m.UID,
    Channel:          ChannelEmail,
    Kind:             NotifyVerifyRegistrationEmail,
    Target:           email,
    Data:             map[string]any{"code": chal.Code, "expires_in": chal.ExpiresIn},
    IdempotencyKey:   chal.ChallengeID,
    DoNotPersistBody: true,
})

// (使用者收信、輸入 code → 後端走以下兩步)

// 4) 驗證 OTPatomic
_ = mOTP.Verify(ctx, &VerifyOTPRequest{
    TenantID: tenantID, ChallengeID: chal.ChallengeID,
    Code: userCode, Purpose: OTPPurposeRegistrationEmail,
})

// 5) 啟用atomicunverified → active
_ = mLifecycle.Activate(ctx, tenantID, m.UID)

Case BOIDCSocial / ZITADEL Hosted UI登入 — 不需 OTP

m, _ := mProv.EnsureFromOIDC(ctx, &EnsureFromOIDCRequest{
    TenantID:      tenantID,
    ZitadelSub:    claims.Sub,
    Email:         claims.Email,
    EmailVerified: claims.EmailVerified,
    DisplayName:   claims.Name,
})
// 直接 active之後 auth.IssueTokenPair

Case CLDAP IdP 首次登入 JIT — 不需 OTP

m, _ := mProv.EnsureFromLDAP(ctx, &EnsureFromLDAPRequest{
    TenantID: tenantID, ExternalID: ldapUUID, LDAPDN: dn,
    Username: username, Email: email, DisplayName: name,
    Groups: groups, Source: RoleSourceLDAPJIT,
})

Case DSCIM Create User — 不需 OTP

m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{
    TenantID: tenantID, ExternalID: scimExternalID,
    UserName: username, Email: email, Active: true, RawPayload: rawJSON,
})

Case E已登入 user 改綁業務 emailatomic 直組 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 RBACPermission 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 collectionpermission

type Permission struct {
    ID          primitive.ObjectID
    Parent      string             // 父權限 IDObjectID hex空 = 掛 root
    Name        string             // 唯一語意名dot notation如 member.info.select
    HTTPMethods string             // 單值如 "GET",或 regex 如 "GET|POST|PATCH";分類節點為空
    HTTPPath    string             // 如 /api/v1/members/*keyMatch2 pattern分類節點為空
    Status      enum.Status        // open | close
    Type        enum.PermissionType // backend_user | frontend_user後台 / 前台菜單)
    CreateAt    int64
    UpdateAt    int64
}

Permission.Name 一旦建立不可改名(被 RolePermission、UI i18n 鍵、Casbin policy.name 欄位引用)。
廢棄走 status=close;新名稱另建新 leaf。重命名要走資料遷移腳本。
HTTPPath 限制:避免裸 *;萬用路徑要明確標出資源根,例如 /api/v1/members/*,禁止 /api/v1/* 之類的廣域 pattern防 keyMatch2 貪婪命中)。

命名規則dot notation與 permission-server 一致)

{domain}.{module}.{action}
{domain}.{module}.{action}.{variant}   # 如 .plain_code

Permission Tree 範例seed 草案)

member.info.management          # 一級:會員資訊管理(分類,無 HTTP
├── member.basic.info           # 二級:基礎資訊
│   ├── member.info.select      # GET  /api/v1/members/me
│   ├── member.info.update      # PATCH /api/v1/members/me
│   └── member.info.select.plain_code  # GET /api/v1/members明碼欄位
├── member.admin.list           # GET  /api/v1/members
├── member.admin.read           # GET  /api/v1/members/:uid
├── member.admin.update         # PATCH /api/v1/members/:uid
└── member.admin.status         # PATCH /api/v1/members/:uid/status

permission.role.management      # 一級:角色權限管理
├── permission.role.read        # GET  /api/v1/permissions/roles
├── permission.role.write       # POST/PUT/DELETE roles
├── permission.assign.write     # POST/DELETE user roles
└── permission.catalog.read     # GET  /api/v1/permissions/catalog

tenant.management
├── tenant.read
├── tenant.ldap.write
└── tenant.sync.trigger

scim.management
├── scim.users.write
└── scim.groups.write

system.management               # 平台級
└── system.tenant.create

分類節點(無 http_path)供 UI 樹狀勾選;葉節點才寫入 Casbin Policy。
新增 Permission 走平台 seed migration租戶不可自行新增 Permission 名稱。

Permission Tree 行為(沿用 permission-server

  1. filterOpenNodes:父節點 status=close → 整棵子樹不可用
  2. getFullParentPermissionIDs:勾選子權限 → 自動加入所有父節點 ID
  3. getFullParentPermission:查 Role 權限 → 回傳含父節點的完整 permission name → status map供前端 UI

6.5 Role EntityB2B 租戶自定義)

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 自訂 Roleis_system=false
  2. 系統 seed 的預設 Roleis_system=true)可修改 Permission 集合,tenant_owner 不可刪
  3. Role 綁定的 Permission 必須是全局 Catalog 中 status=open 的節點
  4. 租戶不可勾選 system.* 權限(除非平台另行開啟)
  5. 至少保留一個 Role 含 permission.role.write,避免租戶自鎖

預設 Role 模板(建立 B2B tenant 時 seed

Key DisplayName 預設勾選Permission Name
tenant_owner 租戶擁有者 system.* 外全部 open 節點
tenant_admin 租戶管理員 member., permission., tenant., scim.
member_manager 會員管理 member.admin.list, member.admin.read, member.admin.status
member 一般會員 member.info.select, member.info.update
viewer 唯讀 member.info.select

B2B 管理員範例:

建立 Rolesales_supervisor
勾選member.admin.list, member.admin.read
指派POST /permissions/users/{uid}/roles { "role_id": "..." }
→ RolePermission.Create → getFullParentPermissionIDs 自動補 parent
→ LoadPolicy 刷新 Casbin

6.6 UserRole / RolePermission

type UserRole struct {
    TenantID string
    UID      string
    RoleID   string   // Role._id hex
    Source   enum.RoleSource  // manual | zitadel | ldap | scim
    CreateAt int64
    UpdateAt int64
}
// Index: { tenant_id, uid, role_id } unique
// Index: { tenant_id, uid }

type RolePermission struct {
    TenantID      string
    RoleID       string
    PermissionID string
    CreateAt     int64
    UpdateAt     int64
}
// Index: { tenant_id, role_id, permission_id } unique

舊 permission-server 的 UserRole 為一 user 一 roleUpdate 覆蓋);新設計支援多角色Middleware 對每個 immutable role key 做 Casbin enforce任一 allow 即通過。

6.7 Casbin RBAC核心授權引擎

模型檔 etc/rbac.confGateway 多租戶版)

[request_definition]
r = tenant, role, path, method

[policy_definition]
p = tenant, role, path, methods, name

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods)
  • keyMatch2:支援 /api/v1/members/* 萬用 path
  • regexMatch:支援 GET|POST 多 method 寫在同一 policy
  • SuperAdmin bypass:不放在 Casbin matcher由 Middleware 先驗證 platform role / allowlist 後短路,並寫入 audit log

Policy 載入(RBACUseCase.LoadPolicy

1. permissionRepo.GetAll → GeneratePermissionTree → filterOpenNodes
2. roleRepo.All(tenant_id) → 每個 role 取 rolePermissionRepo.Get
3. 對每個 (role, permission) 若 http_path + http_method 非空:
     enforcer.AddPolicy(tenantID, role.Key, permission.HTTPPath, permission.HTTPMethods, permission.Name)
4. adapter.SavePolicy(tenant_id) → Redis Listtenant-scoped casbin rules
5. enforcer.LoadPolicy()

授權檢查(RBACUseCase.Check

// 輸入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 rulespermission: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 usecaseRole*, RolePermission*, UserRole*, RoleMapping*)進入時必須驗證 target ID 屬於 tenantIDrepository 查詢一律帶 {tenant_id, _id},找不到回 ErrRoleNotInTenant / ErrUserRoleNotInTenant
Logic 層禁止把 path 的 :id 直接丟 usecase 而不帶 tenant_id

6.9 Middleware 授權流程

RequestJwtRevokeMiddleware 已驗過 JWT + auth_gen
  1. 取 ctx.tenant_id, ctx.uid
  2. userRoleUC.GetRoleKeys → []Role.Key走 perm:user_roles cache
  3. 對每個 roleKey enforce(tenantID, roleKey, path, method)
     聚合所有 allow 結果為 []CheckResult
  4. 若無任何 allow → 403 Forbidden
  5. 聚合規則:
       - PermissionNames = 所有 allow 命中的 permission.Name去重
       - PlainCode = 對每個命中 permission額外 enforce
                    (permission.Name + ".plain_code") 變體;任一通過 → true
  6. 注入 ctx.permission_names, ctx.plain_code

PlainCode 實作*.plain_code 與一般 leaf 一樣寫入 Casbin policyCheck 時主 permission 命中後,用同一 (tenantID, roleKey, path, method) 再做一次帶 .plain_code 的 EnforceEx。沒有 plain_code 變體 → false。
Logic 層讀 ctx.plain_code 決定是否回傳明碼欄位。

Platform Admin bypassJwtRevokeMiddleware 第 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 新的 roleIDssource='zitadel'
  → source='manual' / 'scim' / 'ldap' 的指派不受影響

跨來源衝突原則UserRole 為「並集」,任一 source 指派的 role 即生效revoke 必須指定 source。

6.11 權限變更生效

事件 動作
RolePermission Create/Delete LoadPolicy(tenant_id) + perm:role_perms:* 快取失效
Role Create/Update/Delete LoadPolicy(tenant_id)
UserRole Assign/Revoke INCR auth:gen + LoadPolicy(tenant_id)
SCIM / LDAP Group 變更 更新 user_roles → LoadPolicy + INCR auth_gen
Permission status 變更(平台) LoadAllPolicies() + 權限快取失效;若變更影響登入狀態再 batch INCR auth_gen

多 Pod 同步機制(已決策)

Channel:    casbin:reload
Payload:    { "tenant_id": "xxx", "ts": 1716120000000 }    # tenant_id == "*" 代表全量
  • 即時通道Pub/Sub
    • Writer每次 LoadPolicy(tenant_id) 完成後 PUBLISH casbin:reload {tenant_id}
    • Subscriber每個 pod 啟動時 SUBSCRIBE;收到後在記憶體中 reload 對應 tenant 的 policy
  • 兜底:每 pod 啟動排程 5min 全量 LoadAllPolicies();防 pub message 漏接pod 啟動瞬間、Redis 連線抖動)
  • 冪等reload 用單一 mutex per tenant同時段多個 message 只觸發一次實際 IO
  • 首次啟動pod 啟動先做一次 LoadAllPolicies(),再開始 SUBSCRIBE
  • 設定:Permission.PolicySyncInterval: 5mPermission.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 固定 Rolememberviewer不提供 Role CRUD 與 Permission 勾選 APICasbin 直接載入 seed 結果)。


7. API 規劃

檔案:generate/api/

7.1 auth.api公開 / 需 JWT 視 API 而定)

Method Path 說明 鑑權
POST /api/v1/auth/token/exchange ZITADEL token → CloudEP JWT 公開
POST /api/v1/auth/token/refresh 刷新 JWT 公開(帶 refresh
POST /api/v1/auth/logout 登出jti 黑名單) JWT
POST /api/v1/auth/revoke-all 撤銷自己所有 sessionINCR auth_gen JWT + Step-up revoke_all_sessions
POST /api/v1/auth/step-up/start 啟動 step-up MFA寄 OTP JWT
POST /api/v1/auth/step-up/confirm 確認 OTP → 簽發短壽 step_up_token JWT

7.2 member.api需 JWT + Casbin

Method Path Casbin 命中 Permission示例 Step-up
GET /api/v1/members/me member.info.select
PATCH /api/v1/members/me member.info.update
PATCH /api/v1/members/me/business-email member.info.update ? change_business_email
PATCH /api/v1/members/me/business-phone member.info.update ? change_business_phone
DELETE /api/v1/members/me member.info.delete ? delete_member
POST /api/v1/members/me/verifications/email/start member.info.update
POST /api/v1/members/me/verifications/email/confirm member.info.update
POST /api/v1/members/me/verifications/phone/start member.info.update
POST /api/v1/members/me/verifications/phone/confirm member.info.update
GET /api/v1/members/me/totp member.info.select
POST /api/v1/members/me/totp/enroll-start member.info.update
POST /api/v1/members/me/totp/enroll-confirm member.info.update
POST /api/v1/members/me/totp/backup-codes member.info.update ? disable_totp
DELETE /api/v1/members/me/totp member.info.update ? disable_totp
GET /api/v1/members member.admin.list
GET /api/v1/members/:uid member.admin.read
PATCH /api/v1/members/:uid member.admin.update
PATCH /api/v1/members/:uid/status member.admin.status ? tenant_admin_force_status

授權由 Casbin 比對實際 path + method 決定,非硬編碼 permission 字串。
Step-up 欄為?者需在 Header 帶 X-Step-Up-Token,且 token claim 的 action 必須與表列 action 一致(見 §5.6)。

7.3 permission.api需 JWT + Casbin

Method Path 說明
GET /api/v1/permissions/catalog 全局 Permission Treeopen 節點)
GET /api/v1/permissions/me 當前使用者的 permission name → status map
GET /api/v1/permissions/roles 列出租戶 Role
POST /api/v1/permissions/roles 建立 RoleB2B
PUT /api/v1/permissions/roles/:id 更新 Role
DELETE /api/v1/permissions/roles/:id 刪除 Role
GET /api/v1/permissions/roles/:id/permissions 取得 Role 勾選的 Permission
PUT /api/v1/permissions/roles/:id/permissions 全量取代 Role 勾選 { "permission_names": [...] }PermissionTree 驗證 + 補 parent
GET /api/v1/permissions/users/:uid/roles 查使用者角色
POST /api/v1/permissions/users/:uid/roles 指派 Role { "role_id": "..." }
DELETE /api/v1/permissions/users/:uid/roles/:role_id 撤銷 Role
GET /api/v1/permissions/role-mappings 外部 Group 映射列表
PUT /api/v1/permissions/role-mappings 新增/更新映射
POST /api/v1/permissions/policy/reload 手動觸發 LoadPolicy平台 Admin

7.4 tenant.api平台 / 租戶 Admin

Method Path Casbin 命中 Permission示例
POST /api/v1/admin/tenants system.tenant.create
GET /api/v1/admin/tenants/:tenant_id tenant.read
PUT /api/v1/admin/tenants/:tenant_id/ldap tenant.ldap.write
POST /api/v1/admin/tenants/:tenant_id/directory-sync tenant.sync.trigger

7.5 scim.apiSCIM Bearer Token非 JWT

已決策路由:以 tenant_id 為 path 參數(不用子域名)

/scim/v2/tenants/{tenant_id}/Users
/scim/v2/tenants/{tenant_id}/Groups
/scim/v2/tenants/{tenant_id}/ServiceProviderConfig
/scim/v2/tenants/{tenant_id}/Schemas

認證:Authorization: Bearer {tenant_scim_token}hash 存於 tenant 設定)

  • {tenant_id} = ZITADEL org_id,與 JWT tenant_id 一致
  • SCIM 請求不走 CloudEP JWT授權由 tenant 級 SCIM token + 可選 Casbin 細分

8. Middleware 鏈

8.1 一般受保護 API

Request
  → go-zero JWT 驗簽
  → JwtRevokeMiddlewarejti 黑名單 + auth_gen
  → TenantContextMiddleware校驗 tenant_id 一致)
  → CasbinRBACMiddlewaretenant_id × role_key × path × method → Allow
  → handler → logic → usecase

8.2 CasbinRBACMiddleware

Platform Admin bypass 在前一層 JwtRevokeMiddleware 第 0 步處理§4.6),此處不重複。

// ?代?
roleKeys, _ := userRoleUC.GetRoleKeys(ctx, tenantID, uid)
var hits []rbac.CheckResult
for _, roleKey := range roleKeys {
    res, _ := rbacUC.Check(ctx, &rbac.CheckRequest{
        TenantID: tenantID, UID: uid,
        RoleKey: roleKey, Path: r.URL.Path, Method: r.Method,
    })
    if res.Allow {
        hits = append(hits, res)
    }
}
if len(hits) == 0 {
    httpx.Error(w, forbidden)
    return
}

names, plain := aggregate(hits) // 去重 + PlainCode OR
ctx = withPermissionNames(ctx, names)
ctx = withPlainCode(ctx, plain)
next(w, r)

8.3 SCIM API

Request
  → ScimAuthMiddlewaretenant_scim_token
  → TenantContextMiddleware
  → handler

8.4 Logic 層補充授權

Casbin 處理 API 級 授權。Logic 內可追加 資源級 判斷:

  • member.info.select vs 查他人:若 path 含 :uid 且 uid ≠ caller需命中 member.admin.read
  • PlainCodeLogic 讀 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 == expectedActiontenant_id / uid 與 ctx 一致、未過期
    3. SETNX auth:stepup:used:{jti}=1,已存在 → 403 step_up_replay
    4. 全部通過 → 執行業務操作
    5. 失敗 → 403 step_up_required + { required_action: "<action>" }

9. 核心流程

9.1 登入 / 換票

Client → ZITADEL OIDC Login含 LDAP IdP
Client → POST /auth/token/exchange { tenant_slug, id_token }
  1. zitadel.VerifyIDToken
  2. tenant.ResolveBySlug → 校驗 org_id
  3. member.EnsureFromOIDC → uid如 AMEX-10000000
  4. permission.SyncFromZitadelClaims → user_roles
  5. auth.IssueTokenPairrole keys 快照, auth_gen
Client ← { access_token, refresh_token, uid }

9.2 受保護 API

Client → GET /api/v1/members/me (Bearer access_jwt)
  1. JWT + 黑名單 + auth_gen
  2. CasbinRBACMiddleware → Check(role, "/api/v1/members/me", "GET")
  3. member.GetByUID

9.3 B2B 自定義 Role + 勾選 Permission

Tenant Admin → PUT /api/v1/permissions/roles/{id}/permissions
  { "permission_names": ["member.admin.list", "member.admin.read"] }
  → RolePermissionUC.Replace全量取代
  → PermissionTree.getFullParentPermissionIDs自動補 parent
  → RBACUC.LoadPolicy(tenant_id) + 廣播 reload見 §6.11

Tenant Admin → POST /api/v1/permissions/users/{uid}/roles
  { "role_id": "..." }
  → UserRoleUC.Assign(tenantID, uid, roleID, source=manual)
  → INCR auth_gen + DEL perm:user_roles cache

9.4 停權

Admin → PATCH /api/v1/members/:uid/status { status: "suspended" }
        Header: X-Step-Up-Token: <step_up_token, action=tenant_admin_force_status>
  1. Casbin enforce 命中 member.admin.status
  2. Logic 驗 step_up_token + action 一致
  3. member.UpdateStatus
  4. auth.RevokeAllForUserINCR auth:gen:{tenant_id}:{uid}

9.5 業務 Email 驗證

Client → POST /api/v1/members/me/verifications/email/start { target: "biz@foo.com" }
  1. (可選) 檢查 target 未被同租戶其他 member 使用
  2. 檢查 verify:rate:{tenant}:{uid}:email 不存在60s 冷卻)
  3. 生成 6 碼 OTP → bcrypt 存 verify:otp:{tenant}:{uid}:email:{challenge_id} TTL 5min
  4. NotificationClient.Email.Send(target, template=VerifyEmail, data={code})
  5. SETEX verify:rate:{tenant}:{uid}:email 60
  6. audit log
Client ← { challenge_id, expires_in: 300 }

Client → POST /api/v1/members/me/verifications/email/confirm { challenge_id, code }
  1. 讀 challenge過期或失敗 5 次 → 拒絕
  2. bcrypt compare失敗 → INCR AttemptCnt → 拒絕
  3. 成功 → member.BusinessEmail = target, BusinessEmailVerified = true, BusinessEmailVerifiedAt = now
  4. DEL challenge
  5. audit log
Client ← { verified: true }

phone 流程同上OTP 通道走 SMS Providertemplate 為 VerifyPhone

9.6 Step-up MFA + 改業務 Email

Client → POST /api/v1/auth/step-up/start { action: "change_business_email" }
  1. 從 ctx.uid 讀 member要求 BusinessEmailVerified || BusinessPhoneVerified
  2. 選通道:優先 phone如已 verified否則 email
  3. 生成 OTP → 寄出(步驟同 §9.5
Client ← { challenge_id, channel, expires_in: 300 }

Client → POST /api/v1/auth/step-up/confirm { challenge_id, code, action: "change_business_email" }
  1. bcrypt 比對;驗 challenge.kind == step_up && target == action
  2. auth.StepUpTokenUseCase.Issue(tenant, uid, action) → JWT (typ=step_up, action, TTL 5min)
Client ← { step_up_token, token_type: "step_up", expires_in: 300 }

Client → PATCH /api/v1/members/me/business-email { new_email: "new@foo.com" }
        Header: X-Step-Up-Token: <step_up_token>
  1. Middleware 通過(一般 JWT + Casbin
  2. Logic step-up 守門(見 §8.4
  3. 重設 BusinessEmailVerified = falseBusinessEmail = new_email
  4. 內部觸發 §9.5 對 new_email 重新發 OTP或直接回 challenge_id 給前端)
  5. audit log含舊 / 新 email、step_up jti、IP、UA

10. LDAP 與 SCIM

10.1 三條 Provisioning 路徑

路徑 適用 說明
SCIM → ZITADEL → Gateway 有 HR / Entra ID / Okta 企業推送使用者
ZITADEL LDAP IdP 用戶登入時 JIT 首次登入建立 member
Directory Sync Worker 無 SCIM 的 AD / OpenLDAP 定時同步 + 離職偵測

10.2 LDAP 設定AD + OpenLDAP

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 UIDAMEX-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封裝各家 SDKmodel 層流程、模板、retry、audit、idempotency。

11.3 介面

type NotifierUseCase interface {
    // 同步發送:取得結果與 provider id失敗回 error
    Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
    // 異步排隊:寫 Mongo outbox + 入 channelworker 拉走重試;高吞吐用
    Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error)
    // 查詢單筆狀態
    Get(ctx context.Context, tenantID, notificationID string) (*NotificationDTO, error)
}

type SendRequest struct {
    TenantID    string
    UID         string            // 可為空(系統通知)
    Channel     enum.Channel      // email | sms | push | webhook
    Kind        enum.NotifyKind   // verify_email | verify_phone | step_up | system_alert | ...
    Target      string            // 收件位址email / phone / device_token / url
    Locale      string            // zh-tw | en-us未指定走 tenant.default_locale
    Data        map[string]any    // 模板變數
    Severity    enum.Severity     // info | warn | critical
    IdempotencyKey string         // 業務 key同 key 不重發
    DoNotPersistBody bool         // OTP 等敏感內容不入庫,只記 metadata
}

OTP 等敏感內容DoNotPersistBody=true → notification.body 留空,只記 channel/kind/target hash/provider_message_id/status避免 audit DB 出現明碼 OTP。

11.4 Entity 與 Collection

// 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
}

Templatein-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_dlq collectionadmin API 可手動 retry

11.6 與業務模組的呼叫關係

呼叫者 Kind Channel 模式
member.VerificationUseCase verify_email / verify_phone email / sms 同步(要立即知道送達 / 失敗)
member.StepUpUseCase step_up_email / step_up_phone email / sms 同步
member.AdminUseCase(停權告知) account_suspended email 異步
tenant.UseCase(租戶建立完成) tenant_welcome email 異步
ops alert高異動率 / DLQ 滿) ops_alert email / webhook 同步critical

OTP 必須同步,否則 client 無法回報「OTP 已寄出」的明確錯誤;其他通知優先異步避免拖慢業務 API。

11.7 與 Audit Log 的關係

每筆 Notification 寫入時同步寫 audit log

action = notification.sent | notification.failed | notification.dropped
actor  = system 或 caller uid
target = { kind: notification, id: notification_id, channel, kind }
metadata = { provider, provider_message_id, target_hash }

audit log 不重複存 body已決策 §20.1 critical 同步寫的範圍不含通知本體,僅元數據)。

11.8 安全與 PII

  • Target 不直接 persistTargetHashsha256便於去重、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 縮寫 AMEXACME
Sequence 十進位遞增整數,起始 10000000(沿用 InitAutoID 語意) 10000000
分隔符 固定 - AMEX-10000000
  • 人類可讀、客服可逐字口述
  • 不含 UUID / base64 亂碼
  • UIDPrefix 全平台唯一(已決策);客服輸入 UID 即可定位 tenant + member
  • 不同租戶不可相同 UIDPrefix;同 prefix 內 Sequence 從 10000000 起跳

12.2 產生Bucket 取號,支援單租戶 50 萬)

Redis: member:seq:{tenant_id}    counter初始 10000000
每個 pod 啟動或耗盡時 INCRBY 500 取一個 bucket在記憶體內遞號
UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10)
  • 並發保護{ tenant_id, uid } unique index。EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM / CreateUnverified 命中 dup keyE11000→ fallback GetByZitadelUserIDGetByEmail 取既有 member。
  • Pod crash 容忍bucket 內未用完的號丟失可接受UID 不要求嚴格連續、不要求嚴格遞增;只要求租戶內唯一)。
  • UIDPrefix unique indextenants.{ 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 租戶 Roletenant_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 映射以 identities collection 為 source of truthmembers.zitadel_user_id 若保留,只作反查快取/denormalized 欄位,更新需由同一 transaction 或補償流程維持一致。

時間欄位CreateAt / UpdateAt 統一為 epoch millisecondsUTC。對外 SCIM meta.created / meta.lastModified 由 SCIM mapper 在序列化時轉 RFC3339Nano前端展示由 client 負責 timezone。

13.3 分片鍵100 萬+

Shard Key: { tenant_id: 1, uid: 1 }

單租戶 50 萬會集中在同一 chunkMongoDB 仍可承受;若預期單租戶千萬級再評估 hash 二次分片。


14. Redis Key 命名

authinternal/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

memberinternal/model/member/redis.go

member:profile:{tenant_id}:{uid}   # profile cacheTTL 5~15min
member:sub:{tenant_id}:{sub}       # zitadel_sub → uidTTL 1h
member:seq:{tenant_id}             # UID bucket counter

otp:challenge:{tenant_id}:{challenge_id}            # {purpose, identifier, code_hash, attempts, expire_at}TTL 5min
otp:rate:{tenant_id}:{purpose}:{identifier}         # 重發冷卻 60s
otp:daily:{tenant_id}:{purpose}:{identifier}        # 單日上限 INCRTTL 24h

totp:enroll:{tenant_id}:{uid}                       # enroll 暫存 secret_cipherTTL 10min
totp:used:{tenant_id}:{uid}:{timestep}              # TOTP code 防重放TTL 90s

notificationinternal/model/notification/redis.go

notif:idem:{tenant_id}:{kind}:{idempotency_key}    # idempotency 結果快取TTL 24h
notif:quota:{tenant_id}:{channel}                   # 每租戶每通道 quotaINCR + TTL
notif:retry:zset                                    # 異步重試排程score = next_retry_at_ms

permissioninternal/model/permission/redis.go

permission:casbin:rules:{tenant_id}   # Casbin policy rulesList of JSON
permission:tree:open                 # 可選open 節點 cache
perm:role_perms:{tenant_id}:{role_id}  # role → permission namesTTL 30min
perm:user_roles:{tenant_id}:{uid}      # uid → role keysTTL 5min

15. 規模與性能100 萬+ / 單租戶 50 萬)

項目 策略
Gateway 無狀態,水平擴展
MongoDB Sharding + Replica Set讀走 secondary
ListMembers Cursor 分頁,禁止 deep offset
Authorize Casbin EnforceEx?存 + Redis policy
LoadPolicy ?更?增量cron 5min 全量兜底
JWT → UID Redis cache 1h
Directory Sync 500 users / batchrate limit ZITADEL API
Access Token TTL 15min降低撤銷窗口

容量粗估

100 萬 members × ~2KB  ? 2GB不含 index
indexes               ? 1~2GB
→ 單集群可承受,建議 3 node replica set 起跳

16. 目錄結構

gateway/
├── generate/api/
│   ├── auth.api
│   ├── member.api
│   ├── permission.api
│   ├── tenant.api
│   └── scim.api
│
├── internal/
│   ├── middleware/
│   │   ├── jwt_revoke.go
│   │   ├── tenant_context.go
│   │   ├── require_permission.go
│   │   └── scim_auth.go
│   │
│   ├── library/
│   │   ├── zitadel/
│   │   │   ├── oidc.go
│   │   │   └── management.go
│   │   ├── ldap/
│   │   │   ├── client.go
│   │   │   └── attrmap.go
│   │   ├── casbin/                 # Enforcer 初始化 helper
│   │   ├── uid/
│   │   │   ├── encode.go
│   │   │   └── generator.go
│   │   ├── totp/                   # RFC 6238 演算法、QR 生成
│   │   │   ├── totp.go
│   │   │   └── backup_code.go
│   │   ├── crypto/                 # AES-GCM secret 加解密 + KMS
│   │   │   └── secret.go
│   │   └── notification/           # Provider 實作(純 IO 封裝)
│   │       ├── email/
│   │       │   ├── sendgrid.go
│   │       │   ├── ses.go
│   │       │   └── smtp.go
│   │       ├── sms/
│   │       │   ├── twilio.go
│   │       │   ├── sns.go
│   │       │   └── smsapi.go
│   │       └── push/               # 預留
│   │
│   ├── model/
│   │   ├── auth/
│   │   │   └── ...
│   │   ├── member/                 # 含 verification / step_up / totp usecase
│   │   │   └── ...
│   │   ├── notification/           # 統一通知入口
│   │   │   ├── entity/
│   │   │   │   └── notification.go
│   │   │   ├── enum/
│   │   │   │   ├── channel.go
│   │   │   │   ├── kind.go
│   │   │   │   └── status.go
│   │   │   ├── repository/
│   │   │   │   └── notification.go
│   │   │   ├── usecase/
│   │   │   │   ├── notifier.go
│   │   │   │   ├── template.go
│   │   │   │   └── worker.go
│   │   │   ├── config/
│   │   │   ├── errors.go
│   │   │   └── redis.go
│   │   └── permission/
│   │       ├── entity/
│   │       │   ├── permission.go
│   │       │   ├── role.go
│   │       │   ├── user_role.go
│   │       │   ├── role_permission.go
│   │       │   └── role_mapping.go
│   │       ├── enum/
│   │       │   ├── status.go
│   │       │   └── permission_type.go
│   │       ├── repository/
│   │       │   ├── permission.go
│   │       │   ├── role.go
│   │       │   ├── user_role.go
│   │       │   ├── role_permission.go
│   │       │   ├── role_mapping.go
│   │       │   └── casbin_redis_adapter.go   # 沿用 permission-server
│   │       ├── usecase/
│   │       │   ├── permission_tree.go        # 沿用 permission-server
│   │       │   ├── rbac.go                   # Casbin LoadPolicy / Check
│   │       │   ├── permission.go
│   │       │   ├── role.go
│   │       │   ├── role_permission.go
│   │       │   ├── user_role.go
│   │       │   ├── role_mapping.go
│   │       │   └── authorization_query.go
│   │       ├── rbac/
│   │       │   └── rule.go                   # Casbin Rule struct
│   │       ├── config/
│   │       ├── errors.go
│   │       ├── redis.go
│   │       └── mock/
│   │
│   └── worker/
│       ├── directory_sync/
│       ├── policy_sync/            # 可選:定時 LoadPolicy
│       ├── notification_retry/     # 異步重試、DLQ 巡檢
│       └── member_anonymize/       # 軟刪 30 天後匿名化§5.7
│
├── etc/
│   ├── gateway.yaml
│   └── rbac.conf                   # Casbin 模型(沿用 permission-server
│
└── docs/
    ├── model.md
    └── identity-member-design.md   # 本文件

17. 設定檔

etc/gateway.yaml 擴充草案:

Name: gateway
Host: 0.0.0.0
Port: 8888

Auth:
  AccessSecret: ${JWT_ACCESS_SECRET}
  AccessExpire: 900

RefreshAuth:
  AccessSecret: ${JWT_REFRESH_SECRET}
  AccessExpire: 604800

Zitadel:
  Issuer: https://id.internal.example.com          # self-hosted 內網
  ClientID: ${ZITADEL_CLIENT_ID}
  JWKSUrl: https://id.internal.example.com/oauth/v2/keys
  MgmtURL: https://id.internal.example.com/management/v1
  MgmtToken: ${ZITADEL_MGMT_TOKEN}
  EnforceAdminMFA: true        # admin 級 roletenant_owner/tenant_admin/platform_super_admin強制 TOTP
  # Self-hostedLDAP IdP 由 ZITADEL 直連企業 AD/OpenLDAP

StepUp:
  TokenSecret: ${JWT_STEPUP_SECRET}
  TokenTTLSeconds: 300
  AllowedActions:
    - change_business_email
    - change_business_phone
    - delete_member
    - tenant_admin_force_status
    - revoke_all_sessions

Verification:
  OTPLength: 6
  OTPTTLSeconds: 300
  ResendCooldownSeconds: 60
  DailyLimit: 10
  MaxAttempts: 5

TOTP:
  Issuer: CloudEP                  # 顯示在 Authenticator App 上的名稱
  Algorithm: SHA1                  # 相容 Google Authenticator
  Digits: 6
  PeriodSeconds: 30
  Window: 1                        # 容忍 ±1 個 30s 區間
  BackupCodeCount: 10
  BackupCodeLength: 12             # hex chars
  SecretKEK: ${TOTP_KEK}           # AES-256 KEK建議走 KMS / Vault
  EnrollTTLSeconds: 600

Notification:
  DefaultLocale: zh-tw
  Async:
    QueueRedisKey: notif:retry:zset
    Worker: 4                      # worker goroutine 數
    MaxRetry: 5
    BackoffSeconds: [1, 5, 30, 300, 1800]
  RatePerTenant:                   # 每租戶通道配額(防爆發 / 防濫用)
    Email: 10000                   # 每天
    SMS: 5000
  Email:
    Provider: sendgrid             # sendgrid | ses | smtp
    APIKey: ${SENDGRID_API_KEY}
    From: noreply@example.com
    Templates:                     # 對應 TemplateRegistry key → provider template id
      verify_email: d-xxxxxxxxxxxxx
      step_up_email: d-yyyyyyyyyyyyy
      account_suspended: d-zzzzzzzzzzzzz
      tenant_welcome: d-aaaaaaaaaaaaa
  SMS:
    Provider: twilio               # twilio | sns | smsapi
    AccountSID: ${TWILIO_ACCOUNT_SID}
    AuthToken: ${TWILIO_AUTH_TOKEN}
    From: "+1234567890"
    Templates:
      verify_phone: "Your verification code is {code} (valid {expires_in}s)"
      step_up_phone: "Step-up code: {code}"
  Push:
    Enabled: false                  # 預留
  Webhook:
    HMACSecret: ${NOTIF_WEBHOOK_HMAC}

Mongo:
  # 見 internal/library/mongo 設定

Redis:
  Host: 127.0.0.1:6379
  Type: node

Member:
  DefaultLanguage: zh-tw
  DefaultCurrency: TWD

Permission:
  RBACModelPath: etc/rbac.conf
  PolicySyncInterval: 5m
  PolicyReloadChannel: casbin:reload      # Redis Pub/Sub 通道即時通知5m cron 兜底)
  PlatformAdminTenantID: ${PLATFORM_ADMIN_TENANT_ID}
  PlatformAdminRoleKey: platform_super_admin
  PlatformAdminAllowlistUIDs: ${PLATFORM_ADMIN_ALLOWLIST_UIDS}  # break-glass 用,必須 audit
  CacheTTLSeconds: 300

DirectorySync:
  MissingThreshold: 3
  MaxChangeRatio: 0.20
  DryRunOnFirstSync: true
  DefaultWindow: 24h
  AlertSink: ${OPS_WEBHOOK_URL}

AuditLog:
  Sink: mongo                      # mongo | otel | dual
  Mongo:
    DB: gateway_audit              # 建議獨立 DB instance / replica set
    Collection: audit_logs
    BatchSize: 100
    FlushInterval: 1s
    TTLDays: 90
  OTEL:
    Endpoint: ${OTEL_ENDPOINT}     # Sink = otel / dual 時生效

RateLimit:
  Enabled: true
  RedisPrefix: rl
  WindowSeconds: 60
  Rules:
    - Match: /api/v1/auth/*
      ByIP: 60                    # 60 req / min / IP
      ByUID: 30                   # 30 req / min / UID已登入時
    - Match: /api/v1/auth/step-up/*
      ByUID: 10
    - Match: /scim/v2/*
      ByToken: 6000               # 6000 req / min / SCIM token約 100rps
    - Match: /api/v1/*
      ByUID: 600                  # 一般 API 上限
      ByIP: 1200

18. 實施順序

階段 內容 產出
P0 目錄骨架、entity、redis key、config、make seed-platform-admin CLI(建首位 platform admin uid + role 可啟動、可連 Mongo/Redis平台 admin 可登入
P1 UID generator + ProvisioningUseCaseOIDC/LDAP/SCIM 三變體)+ token exchange 可登入取得 JWT + 可讀 UID
P2 JWT middleware + jti 黑名單 + auth_gen + logout/refresh 完整 Token 生命週期
P3 Permission seed + PermissionTree + Casbin RBAC + Redis Adapter 可 LoadPolicy / Check
P3.5 Notification Module統一入口 + Email/SMS Provider+ Verification + Step-up MFA + TOTP 業務驗證 + TOTP step-up + 高風險守門
P4 member profile API + 預設 Role seed + CasbinRBACMiddleware /members/me + API 授權生效
P5 RolePermission + UserRole + B2B Role CRUD + Permission 勾選 API 租戶完全自定義
P6 Tenant 建立 + ZITADEL CreateOrg + LDAP 設定 多租戶
P7 Directory Sync WorkerAD + OpenLDAP+ §10.4 guardrail 企業目錄同步(誤判保護完備)
P8 SCIM 2.0 endpoint + Group 映射 企業 provisioning
P8.5 Audit log sinkMongo 獨立 collection+ Rate Limit middleware見 §20 可審計 / 防濫用
P9 壓測100 萬 seed、sharding、調優、JWT kid 多版本驗證 上線準備

19. 已決策事項

# 議題 決策 設計影響
1 UID 格式 {Prefix}-{Sequence},如 AMEX-10000000 §12Sequence 起跳 10000000
2 SCIM 路由 /scim/v2/tenants/{tenant_id}/... §7.5、§10.3
3 ZITADEL 部署 Self-hosted §3.3LDAP 內網/VPN 連線
4 權限變更生效 UserRole 變更 INCR auth_genRolePermission 變更 reload policy + cache invalidate §4.5、§6.11
5 B2C 租戶 唯讀 seed 模板,不可自定義 Role §6.12B2C 禁用 Role CRUD API
6 Refresh Token 輪換 + 舊 refresh jti 黑名單 §4.5 Refresh 輪換
7 Casbin 多租戶隔離 policy 帶 tenant_id + immutable role_key §6.7;避免同名 role 跨租戶污染
8 SCIM externalId 保留給客戶端外部識別,不等於 Gateway UID §10.3Gateway UID 作為 SCIM id 或 extension
9 Platform Admin bypass 平台 role + allowlist必須 audit §6.7、§8.2;不放在 Casbin matcher
10 UIDPrefix 全平台唯一tenants.uid_prefix unique index §12.2
11 JWT Claims 內容 不放 role / permission 快照,每次查 cache §4.3
12 Refresh Token Reuse 舊 refresh 二次使用 = 盜用 → INCR auth_gen + audit §4.5
13 Token Exchange 防重放 id_token nonce SETNX + iat 5 分鐘窗口 §4.5
14 Logout 對應 Issue 時 redis 記 access?refresh jti pair §4.5
15 RolePermission API 語意 PUT 全量取代 { permission_names: [...] } + 強制帶 tenant_id §6.8、§7.3、§9.3
16 外部來源 UserRole 按 source 隔離 Replacemanual 永不被洗 §6.10
17 PlainCode 實作 Casbin 額外查 .plain_code 變體,多 role allow 結果取 OR §6.9
18 Permission.Name 建立後不可改名;廢棄走 status=close + 新建 §6.4
19 註冊路徑 預設走 ZITADEL Hosted UIB2C/ LDAP / SCIMB2B保留 platform-native usecaseLifecycleUseCase.CreateUnverified + OTPUseCase + Activate)供未來開通 Gateway 原生註冊(含 email OTP 驗證) §3.4、§5.2.1、§5.9
20 身份 vs 業務驗證分層 ZITADEL 管登入身份Gateway member 自驗業務 email / phone §1.2、§5.4
21 Step-up MFA 啟用;高風險 action 需 5min 單次性 step_up_token §5.6、§9.6
22 OTP 投遞通道 自送(透過 Notification Module 包 Email / SMS Provider §5.5、§11、§17
23 MFA 強制策略 平台強制 admin role 走 ZITADEL TOTP;一般 user 預設不強制,高風險走 Step-up §3.5
24 KYC 不在初版範圍
25 業務 TOTPAuthenticator App 啟用Gateway 自存 AES-GCM 加密 secret與 ZITADEL 身份 TOTP 獨立 §5.8
26 Step-up 通道優先序 TOTP > SMS > EmailStart 時依 enrolled 狀態挑通道,可由 client prefer_channel 覆寫 §5.6
27 Notification Module 獨立 model 模組 internal/model/notification/所有 outbound 通訊統一走 NotifierUseCaselibrary 層為 provider 純 IO 封裝 §11
28 OTP 入庫策略 OTP / step-up 等敏感內容 DoNotPersistBody=truenotification 紀錄僅留 metadatatarget_hash、provider_message_id、status §11.3、§11.8
29 UseCase 分層 Atomic primitives + Composite 兩層原子動作Profile / Lifecycle / Provisioning / OTP / TOTP可任意組合CompositeVerification / StepUp為常用組合預封裝logic 可選擇路徑 §5.2
30 OTP 設計 Purpose-agnostic atomic primitiveOTPUseCase.Generate / Verify / Invalidatepurpose 標識用途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_idstatus=active;失敗走補償 cron 重試或人工標 failed §3.1、§7.4
D Platform Admin Bootstrap make seed-platform-admin CLI(建首個 platform admin uid + role為主PLATFORM_ADMIN_ALLOWLIST_UIDS 環境變數作 break-glass強制 audit §18 P0
E Hybrid 租戶分流 雙欄並存Member.Origin主來源zitadel_local / ldap / scim+ UserRole.Source(每個 role 指派來源sync replace 看 source、唯讀欄位看 origin §3.2、§5、§6.10
F SCIM endpoint 授權 初版 tenant 級 SCIM Token 全權read+write+ IP allowlist + rate limit + token rotationv2 再加 scim.users.write / scim.groups.write scope §7.5
G Audit log sink 獨立 Mongo audit_logs collection(建議獨立 DB instance 或獨立 replica set+ TTL 90 天 + 異步 batch flush高風險事件同步寫可選 OTEL log 雙寫歸檔 §4.5、§8.2、§20
H 帳號刪除策略 軟刪 30 天後匿名化:立即 status=deleted + 撤銷 token + ZITADEL disable30 天 cron 匿名化 PII 欄位email/phone/displayName/avatar/zitadel_sub/business_*);保留 uid/tenant_id/timestamps/audit 連續性 §5.3、§5.7
I Member 欄位 SoT 分欄位策略身份欄位zitadel_sub、IdP email/name、ZITADEL status→ ZITADEL 為準業務欄位business_email/phone、language、currency、avatar→ Gateway 為準provisioning 欄位external_id、ldap_dn→ 來源系統為準 §5、§9.1
J Directory Sync 誤判保護 連 3 次(連續 3 天)找不到才 suspend、單次 sync 異動 > 20% 自動轉 dry-run + 告警、首次部署強制 dry-run、刪除須 cron 通過全部 guardrail §10.4
K Rate Limiting go-zero middleware + Redis sliding-window 多維IP / UID / TenantSCIMToken 三層;/auth/* 每 IP 60rpm + 每 UID 30rpm/scim/* 每 token 100rps一般 API 每 UID 600rpmOTP 走 §5.5 既有冷卻 §17 RateLimit、§20
L JWT Secret Rotation 支援 kid header + 多 key 並存Access / Refresh / Step-up 各自獨立 key set簽發用最新 kid驗證走 active kid 名單;輪換流程:發新 kid → 新 token 用新 kid → 等舊 token expire → 移除舊 kid §4.4

20. Audit Log 與 Rate Limit

20.1 Audit Log

Sink已決策:獨立 Mongo audit_logs collection建議獨立 DB instance 或 replica set避免 OLTP 互拖)。

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 insertBatchSize=100FlushInterval=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 }
  • 匿名化不影響 auditactor / target uid 仍保留(即使 member 已匿名化),達成「最少必要 PII + 連續性」

20.2 Rate Limit

技術選型(已決策)go-zero middleware自製 / 衍生)+ Redis sliding-window。

Key:    rl:{dimension}:{key}:{path_pattern}   # dimension = ip | uid | scim_token
Value:  ZSETtimestamp_ms : nonceTTL = WindowSeconds

演算法

1. now := time.Now().UnixMilli()
2. ZREMRANGEBYSCORE rl:... 0 (now - window_ms)
3. count := ZCARD rl:...
4. if count >= limit → 429 + Retry-After
5. ZADD rl:... now {random}
6. EXPIRE rl:... window

分層命中規則(順序匹配):

路徑 維度 上限
/api/v1/auth/step-up/* UID 10 req/min
/api/v1/auth/* IP / UID 60 / 30 req/min
/scim/v2/* SCIM token 6000 req/min約 100rps
/api/v1/*(其餘) UID / IP 600 / 1200 req/min
  • 公開 endpointexchange / 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 程式碼規範)

實作時兩份文件搭配使用。


附錄 BServiceContext 組裝草案

type ServiceContext struct {
    Config config.Config
    Validator validate.Validate

    // library clients純 IO純粹封裝外部 SDK
    Zitadel       *zitadel.Client
    EmailSender   libemail.Sender
    SMSSender     libsms.Sender
    SecretCipher  libcrypto.Cipher        // TOTP secret 加解密
    TOTPGen       libtotp.Generator

    // usecases
    AuthUC           authusecase.TokenUseCase
    StepUpTokenUC    authusecase.StepUpTokenUseCase
    MemberProvUC     memberusecase.ProvisioningUseCase
    MemberProfileUC  memberusecase.ProfileUseCase
    MemberAdminUC    memberusecase.AdminUseCase
    VerificationUC   memberusecase.VerificationUseCase
    StepUpUC         memberusecase.StepUpUseCase
    TOTPUC           memberusecase.TOTPUseCase
    TenantUC         memberusecase.TenantUseCase
    ScimUC           memberusecase.ScimUseCase

    // notification module
    NotifierUC       notifusecase.NotifierUseCase

    // permission usecases對齊 permission-server 拆分)
    PermRBACUC       permusecase.RBACUseCase
    PermUC           permusecase.PermissionUseCase
    RoleUC           permusecase.RoleUseCase
    RolePermUC       permusecase.RolePermissionUseCase
    UserRoleUC       permusecase.UserRoleUseCase
    RoleMappingUC    permusecase.RoleMappingUseCase
    AuthQueryUC      permusecase.AuthorizationQueryUseCase
}

附錄 Cpermission-server 遷移對照(程式碼級)

permission-server 檔案 Gateway 目標 遷移方式
pkg/usecase/permission_tree.go model/permission/usecase/permission_tree.go 幾乎原樣搬移
pkg/usecase/casbin_redis_rbac.go model/permission/usecase/rbac.go tenant_id + role_key 維度
pkg/repository/casbin_redis_adapter.go model/permission/repository/casbin_redis_adapter.go 改為 tenant-scoped policy key
pkg/domain/rbac/rule.go model/permission/rbac/rule.go 原樣搬移
etc/rbac.conf etc/rbac.conf 加入 tenant request / policy 維度
pkg/usecase/role.go model/permission/usecase/role.go ClientIDTenantID
pkg/usecase/role_permission.go model/permission/usecase/role_permission.go tenant_id 防呆與查詢維度
pkg/usecase/user_role.go model/permission/usecase/user_role.go 改支援多角色
pkg/usecase/token.go model/auth/usecase/token.go 不在 permission 模組
generate/database/seeders/*_permission* generate/database/seeders/ 或 Mongo seed 改為 Gateway seed job

修訂紀錄

日期 版本 說明
2026-05-19 0.1.0 初稿auth + member + permissionB2B 自定義)+ ZITADEL/LDAP/SCIM
2026-05-19 0.2.0 對齊 app-cloudep-permission-serverCasbin RBAC、Permission Tree、Role/RolePermission
2026-05-19 0.3.0 已定案 §1916UID 前綴格式、SCIM tenant_id 路由、ZITADEL self-hosted、auth_gen 強制刷新、B2C 唯讀、Refresh 輪換
2026-05-19 0.4.0 補強多租戶 Casbin、immutable Role.Key、SCIM externalId、Platform Admin bypass 與權限生效策略
2026-05-20 0.5.0 Best-practice 收斂JWT 不放 role 快照、Refresh Reuse Detection、Token Exchange Nonce、Logout pair、RolePermission tenant 防呆 + PUT 全量取代、外部來源 source 隔離、PlainCode 聚合、Permission.Name 不可改、UIDPrefix 全平台唯一、Role.Key 規則、附錄重排為 A→B→C
2026-05-20 0.6.0 補入業務驗證分層Gateway 不提供註冊 API§3.4);新增業務 Email / Phone 自驗§5.4、§9.5Step-up MFA 啟用§5.6、§9.6OTP 自送 Email + SMS Provider§5.5、§17 Notification平台 admin 強制 ZITADEL TOTP§3.5);新增對應 Redis key、API、設定、決策列 1924
2026-05-20 0.7.0 待決策 AL 全數拍板SCIM id = Gateway UID + ZITADEL sub extension§10.3Casbin 多 pod Pub/Sub + 5min cron 兜底§6.11Tenant 建立 saga§3.1Platform Admin seed CLI§18 P0Member.Origin + UserRole.Source 雙欄§5.4、§6.10SCIM token 全權 + IP allowlist§7.5);獨立 audit_logs collection + TTL 90d§20.1);軟刪 30 天匿名化§5.7);分欄位 SoT§5.3Directory Sync guardrail§10.4Redis sliding-window rate limit§20.2JWT kid 多 key 並存§4.4
2026-05-20 0.8.0 抽出獨立 Notification Module§11所有 outbound 通訊統一入口、含 idempotency / 重試 / DLQ / 模板 / 多語、敏感內容 DoNotPersistBody;新增 業務 TOTP§5.8)支援 Google Authenticator與 ZITADEL 身份 TOTP 獨立step-up 通道優先序改為 TOTP > SMS > Email§5.6目錄、ServiceContext、Mongo collections、Redis key、設定檔、實施順序、決策列 2528 同步更新§11§19 章節編號全部 +1
2026-05-20 0.9.0 UseCase 介面契約凍結(業務邏輯暫不實作)§5.2 重寫為 Atomic primitives + Composite 兩層;新增 OTPUseCasepurpose-agnostic atomicLifecycleUseCaseCreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPendingProvisioningUseCaseEnsureFromOIDC / LDAP / SCIM 三變體;ProfileUseCaseSetBusinessEmailVerified / SetBusinessPhoneVerified atomic加回 unverified 狀態(僅 platform-native 路徑);補完 Member entity 欄位、Enum 草案、Request DTO新增 §5.9 編排示例5 case§14 OTP Redis key 改 purpose-based決策列 19 修正、新增 2932