docs: 統整模組 README ↔ SDD 分工,砍重複內容

讓「找規格」跟「日常速查」兩種需求各有歸宿,避免同樣資訊散落多處:

- 改寫 docs/identity-member-design.md:從 Big5 亂碼的 2673 行設計草稿
  → ~200 行 UTF-8 跨模組總覽(架構決策、模組依賴、UID、JWT、Casbin、
  Pub/Sub、Notification 全部一頁看完),不再跟模組 README 重疊
- 新增 internal/model/auth/README.md:合併原 auth-unified-registration
  + auth/SDD 的高層概念,留 SDD 給規格細節
- 精簡 member / permission / notification README:保留 sequence diagram、
  curl、ServiceContext wiring 等日常開發要的東西;逐欄位 schema / Redis
  key TTL / API endpoint list 等規格細節改指向各模組 SDD.md
- 每個 README 頂部加「規格 vs 速查」一行指路,找欄位 → SDD,找流程 → README
- root README 同步補上各模組 README + SDD 並列連結
- code comment 裡的 internal/model/{member,permission}/SDD.md §X.Y 引用
  全部對齊新章節編號

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-05-22 17:18:08 +08:00
parent 1f3eb3c992
commit 55446b9060
13 changed files with 852 additions and 3568 deletions

View File

@ -171,13 +171,22 @@ HTTP Request
更細的說明見各子目錄 README 更細的說明見各子目錄 README
- [generate/api/README.md](generate/api/README.md) — `.api``@respdoc` 約定 - [`generate/api/README.md`](generate/api/README.md) — `.api`、`@respdoc`、middleware 約定
- [internal/response/README.md](internal/response/README.md) — Handler / Logic 分工 - [`internal/response/README.md`](internal/response/README.md) — Handler / Logic 分工
- [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 - [`internal/library/errors/README.md`](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照
- [internal/library/mongo/README.md](internal/library/mongo/README.md) — MongoDB / Redis cache 流程與用法 - [`internal/library/mongo/README.md`](internal/library/mongo/README.md) — MongoDB / Redis cache 流程
- [docs/model.md](docs/model.md) — `internal/model/{module}` 分層entity / repository / usecase - [`internal/library/redis/README.md`](internal/library/redis/README.md) — Redis 連線共用
- [docs/identity-member-design.md](docs/identity-member-design.md) — Identity / Member / Permission 模組架構ZITADEL、LDAP、SCIM、B2B 自定義權限) - [`docs/model.md`](docs/model.md) — `internal/model/{module}` 分層規範
- [docs/auth-unified-registration.md](docs/auth-unified-registration.md) — Gateway 統一註冊 / 登入(`/api/v1/auth/*`,含 Email、Social、JWT - [`docs/identity-member-design.md`](docs/identity-member-design.md) — Identity / Member / Permission 跨模組架構ZITADEL、LDAP、SCIM、B2B 自定義權限)
- [`docs/auth-unified-registration.md`](docs/auth-unified-registration.md) — 統一註冊登入完整時序Email / Social / JWT
- [`docs/e2e-testing.md`](docs/e2e-testing.md) — E2E 測試 + 一鍵 `make e2e-full`
- [`docs/notification-testing.md`](docs/notification-testing.md) — Notification 本機測試
- 模組 README + SDD
- [`auth`](internal/model/auth/README.md) [SDD](internal/model/auth/SDD.md)
- [`member`](internal/model/member/README.md) [SDD](internal/model/member/SDD.md)
- [`permission`](internal/model/permission/README.md) [SDD](internal/model/permission/SDD.md)
- [`notification`](internal/model/notification/README.md) [SDD](internal/model/notification/SDD.md)
- README = 開發速查SDD = 規格書Data Dictionary、API 端點欄位)
## 開發約定 ## 開發約定

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,8 @@
# Matcher: same tenant + same role + path keyMatch2 + method regexMatch # Matcher: same tenant + same role + path keyMatch2 + method regexMatch
# #
# Platform admin bypass is enforced before this matcher (middleware short # Platform admin bypass is enforced before this matcher (middleware short
# circuit) so it does not appear here. See identity-member-design.md §6.7. # circuit) so it does not appear here. See internal/model/permission/SDD.md
# §3.3 (RBAC Model).
[request_definition] [request_definition]
r = tenant, role, path, method r = tenant, role, path, method

View File

@ -0,0 +1,291 @@
# Auth 模組
Gateway 認證領域層邀請碼、註冊稽核、OAuth Session、CloudEP JWT 簽發/刷新/登出。完整 HTTP 流程由 `internal/logic/auth/` 編排,並與 ZITADEL身份、Member會員、Notification通知協作。
> **架構原則**[`docs/model.md`](../../../docs/model.md) §6.1usecase 為 atomic primitive**不可** 呼叫其他 usecase跨模組編排放 `internal/logic/auth/`
>
> **規格 vs 速查**
> - 規格書Data Dictionary `invite_codes` / `registration_metadata`、Redis key、API endpoint list→ [`SDD.md`](./SDD.md)
> - 統一註冊Email/OTP/Social完整時序 + ZITADEL 互動細節 → [`docs/auth-unified-registration.md`](../../../docs/auth-unified-registration.md)
> - 本 README = 範圍 + ServiceContext wiring + 設定 + curl + 快速跟其他模組對齊用
## 範圍
| 範圍內 | 範圍外(委派) |
|--------|-----------------|
| 邀請碼 Validate / Consume | 使用者身份建立 → ZITADEL |
| 註冊稽核 metadata | 會員 profile / OTP → Member |
| OAuth 註冊 / 登入暫存 SessionRedis | 郵件 / 簡訊 → Notification |
| CloudEP JWT access / refresh 生命週期 | RBAC → Permission |
| JWT 黑名單 + jti pair | |
---
## 目錄結構
```
internal/model/auth/
├── README.md # 本檔
├── config/
├── domain/
│ ├── entity/ # InviteCode、RegistrationMetadata
│ ├── enum/ # RegistrationChannelemail / google
│ ├── repository/ # 介面 + Session struct
│ ├── usecase/ # 介面 + DTO
│ ├── const.go # BSON 欄位、Redis key、code hash helper
│ └── errors.go # 領域 sentinel
├── repository/ # Mongo + Redis 實作 + EnsureMongoIndexes
└── usecase/ # 實作 + NewModuleFromParam
```
## Module 與依賴
```mermaid
flowchart TB
Logic["logic/auth\n(orchestration)"]
subgraph M["auth.Module"]
Token[TokenUseCase]
Invite[InviteUseCase]
RegMeta[RegistrationMetaUseCase]
RegSess[RegistrationSessionUseCase]
LoginSess[LoginSessionUseCase]
end
Logic --> Token
Logic --> Invite
Logic --> RegMeta
Logic --> RegSess
Logic --> LoginSess
Logic --> Member[(member.Module)]
Logic --> Zitadel[(library/zitadel)]
Logic --> Notif[(notification)]
```
**ServiceContext 注入:**
| 欄位 | UseCase | 啟用條件 |
|------|---------|----------|
| `AuthToken` | TokenUseCase | JWT secret + Redis |
| `AuthInvite` | InviteUseCase | Mongo + Redis |
| `AuthRegistrationMeta` | RegistrationMetaUseCase | Mongo |
| `AuthRegistrationSession` | RegistrationSessionUseCase | Redis |
| `AuthLoginSession` | LoginSessionUseCase | Redis |
---
## API`/api/v1/auth`
### 公開(無 Bearer
| Method | Path | 說明 |
|--------|------|------|
| POST | `/register` | Email 註冊 → `{challenge_id, expires_in, uid}` |
| POST | `/register/confirm` | OTP 確認 → 核發 JWT |
| POST | `/register/resend` | 重發註冊 OTP |
| POST | `/register/social/start` | 社交註冊起始 → `{oauth_url, session_id}` |
| GET | `/register/social/callback` | OAuth callback → JWT |
| POST | `/login` | 密碼登入ZITADEL ROPG |
| POST | `/login/social/start` | 社交登入起始 |
| GET | `/login/social/callback` | 社交登入 callback |
| POST | `/token/refresh` | 刷新 token pair |
| POST | `/token/exchange` | id_token → CloudEP JWT |
### 需 Bearer
| Method | Path | 說明 |
|--------|------|------|
| POST | `/logout` | 黑名單 access + paired refresh jti |
完整 schema`generate/api/auth.api`;成功 envelope `code=102000`
---
## TokenCloudEP JWT
HS256access / refresh 使用不同 secret。
| Claim | 說明 |
|-------|------|
| `tenant_id` | 租戶 ID |
| `uid` | 會員 UID |
| `typ` | `access``refresh` |
| `auth_gen` | 簽發世代(強制登出時 +1 |
| `jti` | Token 唯一 ID |
| `iat` / `exp` | 簽發 / 過期 |
| Token | 預設 TTL |
|-------|---------|
| Access | 900s15 分鐘) |
| Refresh | 604800s7 天) |
| Registration Session | 600s10 分鐘) |
### Redis Key
| Key | 用途 | TTL |
|-----|------|-----|
| `auth:jwt:pair:{jti}` | access ↔ refresh jti 映射 | token TTL |
| `auth:jwt:bl:{jti}` | 黑名單 jti | 至自然過期 |
| `auth:register:session:{id}` | 社交註冊 OAuth session | 600s |
| `auth:login:session:{id}` | 社交登入 OAuth session | 600s |
| `auth:invite:consume:{tenant}:{hash}` | 邀請碼消費鎖 | 30s |
### Refresh 流程
```mermaid
sequenceDiagram
participant C as Client
participant L as TokenRefreshLogic
participant T as TokenUseCase
participant R as Redis
C->>L: POST /auth/token/refresh {refresh_token}
L->>T: Refresh(refreshToken)
T->>T: Parse + verify typ=refresh
T->>R: Blacklist 舊 refresh jti + paired access jti
T->>T: IssuePairnew access + refresh
T->>R: SavePair(new jti mapping)
T-->>L: TokenPair
L-->>C: AuthTokenData
```
---
## 統一註冊Email + Social
> 取代原 ZITADEL Hosted Page使用者只與 Gateway 互動。Invite 為 B2B 必填、B2C 可選(`Member.Registration.RequireInviteCode`)。
### 三條路徑
```
┌─────────────────────────────────────────────┐
│ 前端「註冊」頁(共用 invite + 條款) │
└────────────┬────────────────┬───────────────┘
▼ ▼
Email + Password Social (Google)
│ │
POST /register POST /register/social/start
│ │ → oauth_url
POST /register/confirm GET /register/social/callback
│ │
└───────┬────────┘
IssueTokenPairCloudEP JWT
```
### Email 路徑
1. Logic 驗 tenant_slug + invite + 條款 + 密碼強度 + email 格式
2. Logic 消耗 inviteRedis SETNX lock + Mongo `$inc used_count`
3. ZITADEL `CreateHumanUser`
4. `member.Lifecycle.CreateUnverified``origin=platform_native`
5. 寫 registration metadata
6. `member.OTP.Generate(purpose=registration_email)` + `notifier.Send(verify_registration_email)`
7. 回 `{challenge_id, expires_in}`**不發 JWT**
8. `POST /register/confirm`OTP verify → `Activate``IssueTokenPair`
### Social 路徑Google
- Invite 必填;在 OAuth redirect **前** 綁定 registration sessionRedis TTL 600s
- callback 才 **消耗** invite避免 IdP 中途取消)
- `member.Provisioning.EnsureFromOIDC`(同 sub 已存在則視為登入)
- `Member.Registration.TrustSocialEmailVerified=true`IdP `email_verified=true` 直接 `Activate`,否則走 OTP
### 失敗補償
| 步驟 | 補償 |
|------|------|
| Invite 無效 | 直接 4xx無 side effect |
| ZITADEL 建 user OK / member 失敗 | Logic 呼叫 `zitadel.DeactivateUser(sub)` |
| Member OK / Send OTP 失敗 | `OTP.Invalidate(challenge_id)` |
| confirm 時 Activate 失敗 | 5xx保留 challenge 可重試 |
> Invite 在 ZITADEL/member 失敗時 **不自動回滾**(防刷;可 admin 補發)。
### Logic 商務驗證一覽(不下沉 usecase
- invite 必填 / 過期 / 限新 user
- 條款版本接受
- tenant 是否允許 B2C 註冊
- 密碼政策
- 註冊 rate limit key 組合
- SocialOAuth state 與 registration session 綁定
---
## Invite Code
| 欄位 | 說明 |
|------|------|
| `tenant_id` | 租戶 |
| `code_hash` | SHA-256(normalized code)**永不明文** |
| `max_uses` / `used_count` | 總次數 / 已用 |
| `expires_at` | 0 = 永不過期 |
| `new_users_only` | 限新用戶(社交註冊用) |
- 索引:`(tenant_id, code_hash)` unique
- Validate / Consume 介面:`InviteUseCase`
- Email 註冊在 `/register` 起始即 ConsumeSocial 在 Callback 才 Consume
---
## Rate Limit建議由 middleware 落地)
| Key | 限制 |
|-----|------|
| `auth:register:ip:{ip}` | 10 / hour |
| `auth:register:email:{tenant}:{email}` | 3 / hour |
| `auth:register:invite_fail:{ip}` | 20 / hour |
OTP resend 沿用 `member.VerifyRate`
---
## 錯誤碼Auth scope 28
| 情境 | errb |
|------|------|
| invite 無效 | `InputInvalidFormat` / `ResNotFound("invite")` |
| invite 用盡 | `ResInsufficientQuota` |
| email 已註冊 | `ResAlreadyExist` |
| OTP 錯誤 | `AuthForbidden` + cause |
| tenant 不允許註冊 | `AuthForbidden` |
| 未接受條款 | `InputMissingRequired` |
| ZITADEL 下游失敗 | `SvcThirdParty` |
| DB 失敗 | `DBError` via wrapRepoErr |
Scope / category 完整對照:[`internal/library/errors/README.md`](../../library/errors/README.md)。
---
## 設定(`etc/gateway.dev.yaml`
```yaml
Auth:
AccessExpire: 900
RefreshExpire: 604800
ActiveKID: v1
AccessSecret: ... # 32+ bytesprod 走 env / secret manager
RefreshSecret: ...
RegistrationSessionTTLSeconds: 600
Zitadel:
Issuer: https://zitadel.internal
ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
GoogleClientID: ...
GoogleClientSecret: ...
DefaultOrgID: ...
```
---
## 測試
- **單元**`go test ./internal/model/auth/...`invite consume 並發、JWT parse/refresh、errb 映射)
- **E2E**:見 [`docs/e2e-testing.md`](../../../docs/e2e-testing.md)`TestAuth_*` / `TestZZZ_AuthTokenRefreshAndLogout`ZITADEL 整合路徑A-01~A-09需 staging 環境
---
## 相關文件
- [`docs/model.md`](../../../docs/model.md) — Clean Architecture 分層
- [`generate/api/README.md`](../../../generate/api/README.md) — `.api` + middleware 約定
- [`internal/library/errors/README.md`](../../library/errors/README.md) — 8 碼錯誤碼
- [`internal/model/member/README.md`](../member/README.md) — Member 模組

View File

@ -1,54 +1,37 @@
# Member 模組 # Member 模組
Gateway 的會員核心:涵蓋 **Tenant(租戶)**、**Member(會員 profile)**、**Identity(外部身份對映)** 三大實體,以及租戶內 readable UID、業務 email/phone OTP 驗證、TOTP step-up MFA、resend / daily 配額等業務功能 Gateway 的會員核心**Tenant / Member / Identity** 三大實體,加上可讀 UID、業務 email/phone OTP、TOTP step-up MFA、重發 / 每日配額
> **架構原則**(`docs/model.md` §6.1): > **架構原則**[`docs/model.md`](../../../docs/model.md) §6.1usecase **不可** 呼叫其他 usecase多步流程OTP → 寄信 → 驗碼 → flip flag一律在 `internal/logic/member/` 編排。
> usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。 >
> 本 module 所有 usecase 都是 **atomic primitives** > **規格 vs 速查**:完整 Mongo collection 欄位、Redis key TTL、API endpoint list → [`SDD.md`](./SDD.md)。本 README 只保留 sequence diagram、curl、ServiceContext wiring 等日常開發要看的東西
--- ---
## 目錄 ## 核心實體
- [核心概念](#核心概念)
- [目錄結構](#目錄結構)
- [Module 結構與依賴](#module-結構與依賴)
- [Atomic UseCase 一覽](#atomic-usecase-一覽)
- [資料儲存](#資料儲存)
- [生命週期與狀態機](#生命週期與狀態機)
- [核心流程時序圖](#核心流程時序圖)
- [1. 模組裝配 (NewModuleFromParam)](#1-模組裝配-newmodulefromparam)
- [2. Tenant 建立](#2-tenant-建立)
- [3. Platform 註冊 (auth + member.Lifecycle)](#3-platform-註冊-auth--memberlifecycle)
- [4. Provisioning — OIDC / LDAP / SCIM](#4-provisioning--oidc--ldap--scim)
- [5. 業務 Email / Phone OTP 驗證](#5-業務-email--phone-otp-驗證)
- [6. TOTP 綁定 / Step-up](#6-totp-綁定--step-up)
- [7. UID 生成](#7-uid-生成)
- [Redis Key 命名](#redis-key-命名)
- [設定](#設定)
- [ServiceContext 注入](#servicecontext-注入)
- [測試](#測試)
---
## 核心概念
| 實體 | 用途 | 主要欄位 | 儲存 | | 實體 | 用途 | 主要欄位 | 儲存 |
| --- | --- | --- | --- | |------|------|---------|------|
| **Tenant** | 租戶元資料 | `tenant_id`、`slug`、`uid_prefix`、`status`、`org_id` | Mongo `tenants` | | **Tenant** | 租戶元資料 | `tenant_id`、`slug`、`uid_prefix`、`status`、`org_id` | Mongo `tenants` |
| **Member** | 會員 profile(租戶範圍) | `tenant_id`+`uid`、`zitadel_user_id`、`status`、`origin`、business email/phone、TOTP cipher | Mongo `members` | | **Member** | 會員 profile租戶範圍 | `(tenant_id, uid)`、`zitadel_user_id`、`status`、`origin`、business email/phone、totp cipher | Mongo `members` |
| **Identity** | 外部 ID → UID 對映 | `zitadel_user_id`、`external_id`、`uid` | Mongo `identities` | | **Identity** | 外部 ID → UID 對映 | `zitadel_user_id`、`external_id`、`uid` | Mongo `identities` |
**Member 雙鍵**:`(tenant_id, uid)` 為對外的可讀主鍵;`zitadel_user_id` 是 OIDC 來源的對映鍵。 對外可讀主鍵:`(tenant_id, uid)`UID 格式 `{UIDPrefix}-{Sequence}`(例:`ACME-10000003`)。
**多租戶等級**:每個 Member 必屬於一個 Tenant,UID 用 `{TenantUIDPrefix}-{Sequence}` 格式(例:`ACME-10000003`)。
### 來源(Origin) **Origin** `platform_native`(前台註冊)/ `oidc`ZITADEL/Social/ `ldap` / `scim`
``` **狀態機:**
platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified)
oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC) ```mermaid
ldap // Directory Sync(Provisioning.EnsureFromLDAP) stateDiagram-v2
scim // SCIM 2.0(Provisioning.EnsureFromSCIM) [*] --> unverified: Lifecycle.CreateUnverified
[*] --> active: Provisioning.Ensure*
unverified --> active: Activate (OTP 通過)
unverified --> deleted: AbortPending (註冊逾時)
active --> suspended: Suspend
suspended --> active: Reactivate
active --> deleted: SoftDelete
suspended --> deleted: SoftDelete
``` ```
--- ---
@ -57,106 +40,39 @@ scim // SCIM 2.0(Provisioning.EnsureFromSCIM)
``` ```
internal/model/member/ internal/model/member/
├── README.md
├── config/ # OTP / TOTP / Registration 設定 ├── config/ # OTP / TOTP / Registration 設定
├── domain/ # 介面、enum、entity、errors、redis key helper ├── domain/ # 介面、enum、entity、errors、redis key
│ ├── const.go # BSON 欄位、UID 常數 │ ├── entity/ # Member / Tenant / Identity
│ ├── entity/ # Member、Tenant、Identity Mongo doc │ ├── enum/ # MemberStatus / Origin / OTPPurpose / TenantStatus / VerifyKind
│ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind │ ├── repository/ # 7 個 repository 介面
│ ├── errors.go # ErrNotFound、ErrDuplicateMember 等 │ ├── usecase/ # 7 個 usecase 介面 + DTO
│ ├── redis.go # GetOTPChallengeRedisKey 等 helper │ ├── const.go # BSON 欄位、UID 常數
│ ├── repository/ # 7 個 repository 介面 │ ├── errors.go # ErrNotFound / ErrDuplicateMember ...
│ └── usecase/ # 7 個 usecase 介面 + DTO │ └── redis.go # GetOTPChallengeRedisKey ...
├── repository/ # Mongo / Redis 實作 ├── repository/ # Mongo + Redis 實作
├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL) ├── totp/ # RFC 6238 純函式
├── usecase/ # 7 個 usecase 實作 + module factory + mapper └── usecase/ # 實作 + module factory
└── README.md # 本檔
``` ```
`domain/` 純介面 + 常數,**不依賴外部 lib**(除 `bson.ObjectID`)。
`usecase/` 只依賴 `domain/`
`repository/` 依賴 `library/mongo`、`library/redis`。
---
## Module 結構與依賴
```mermaid
flowchart TB
Logic["logic 層<br/>(handler 編排)"]
subgraph M["member.Module (atomic usecases)"]
direction LR
OTP["OTP"]
TOTP["TOTP"]
Profile["Profile"]
Lifecycle["Lifecycle"]
Provisioning["Provisioning"]
Tenant["Tenant"]
VerifyRate["VerifyRate"]
end
subgraph R["domain.Repository (介面)"]
MemberRepo["MemberRepository"]
TenantRepo["TenantRepository"]
IdentityRepo["IdentityRepository"]
OTPStore["OTPChallengeStore"]
RateStore["VerifyRateStore"]
TOTPProf["TOTPProfileRepository"]
TOTPEnroll["TOTPEnrollStore"]
TOTPReplay["TOTPReplayStore"]
UIDGen["UIDGenerator"]
end
subgraph I["repository/ 實作"]
Mongo[(MongoDB)]
Redis[(Redis)]
end
Logic -->|單呼叫| M
OTP --> OTPStore
TOTP --> TOTPProf
TOTP --> TOTPEnroll
TOTP --> TOTPReplay
Profile --> MemberRepo
Lifecycle --> MemberRepo
Lifecycle --> TenantRepo
Lifecycle --> UIDGen
Provisioning --> MemberRepo
Provisioning --> IdentityRepo
Provisioning --> TenantRepo
Provisioning --> UIDGen
Tenant --> TenantRepo
VerifyRate --> RateStore
MemberRepo --- Mongo
TenantRepo --- Mongo
IdentityRepo --- Mongo
TOTPProf --- Mongo
OTPStore --- Redis
RateStore --- Redis
TOTPEnroll --- Redis
TOTPReplay --- Redis
UIDGen --- Redis
```
**注入規則**:Module factory 依條件啟用 usecase:
- `Redis` 必填 → `OTP`、`VerifyRate` 永遠存在。
- `MongoConf` 設定 → 啟用 `Profile`、`Lifecycle`、`Tenant`、`Provisioning`。
- `TOTP.SecretKEK` 設定 → 啟用 `TOTP`(否則 `mod.TOTP == nil`)。
--- ---
## Atomic UseCase 一覽 ## Atomic UseCase 一覽
| UseCase | 介面方法 | 職責 | | UseCase | 介面方法 | 職責 |
| --- | --- | --- | |---------|---------|------|
| **TenantUseCase** | `Create` / `ResolveBySlug` | 建立租戶、依 slug 反查 | | **TenantUseCase** | `Create` / `ResolveBySlug` | 建租戶、依 slug 反查 |
| **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換(嚴格的 from→to 檢查) | | **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換 |
| **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | 讀取 / patch 可變欄位、業務 contact 標記已驗證 | | **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | profile 讀寫、業務 contact 驗證標記 |
| **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登/同步 upsert(Member + Identity) | | **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登 upsert冪等 |
| **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 產出/驗證一次性數字碼(bcrypt + Redis) | | **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 一次性數字碼bcrypt + Redis |
| **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) | | **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFAAES-GCM 保護 secret |
| **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | OTP 重發冷卻 + 每日上限 | | **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | resend 冷卻 + 每日上限 |
**Module factory 條件啟用:**
- Redis 必填 → `OTP` / `VerifyRate` 永遠存在
- Mongo 啟用 → `Profile` / `Lifecycle` / `Tenant` / `Provisioning`
- `Member.TOTP.SecretKEK` 啟用 → `TOTP`(否則 `mod.TOTP == nil`
--- ---
@ -164,409 +80,161 @@ flowchart TB
### MongoDB Collections ### MongoDB Collections
| Collection | Entity | 主要索引 | | Collection | 主要索引 |
| --- | --- | --- | |------------|---------|
| `members` | `Member` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)`(sparse) | | `members` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)` sparse |
| `tenants` | `Tenant` | unique `slug`、unique `uid_prefix` | | `tenants` | unique `slug`、unique `uid_prefix` |
| `identities` | `Identity` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` | | `identities` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` |
索引建立由 `repository.EnsureMongoIndexes` 在啟動時執行(對應 `cmd/mongo-index`) 索引建立由 `repository.EnsureMongoIndexes``cmd/mongo-index` 會跑)
### Redis Keys ### Redis Keys
| Key 前綴 | 用途 | TTL | | Key | 用途 | TTL |
| --- | --- | --- | |------|------|-----|
| `member:otp:challenge:{id}` | OTP challenge 主紀錄(bcrypt hash) | `OTP.TTLSeconds`(預設 300) | | `member:otp:challenge:{id}` | OTP challengebcrypt hash | `OTP.TTLSeconds`(預設 300 |
| `member:otp:challenge:{id}:attempts` | OTP 錯誤次數計數 | 同 challenge | | `member:otp:challenge:{id}:attempts` | OTP 錯誤次數 | 同 challenge |
| `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 lock | `OTP.ResendCooldownSeconds`(預設 60) | | `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 | `OTP.ResendCooldownSeconds`(預設 60 |
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限計數 | 24h | | `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 | 24h |
| `member:totp:enroll:{tenant}:{uid}` | 綁定中的 staged secret(AES-GCM cipher) | `TOTP.EnrollTTLSeconds`(預設 600) | | `member:totp:enroll:{tenant}:{uid}` | 綁定中 staged secret cipher | `TOTP.EnrollTTLSeconds`(預設 600 |
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | `TOTP.ReplayTTLSeconds`(預設 90) | | `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP replay 保護 | `TOTP.ReplayTTLSeconds`(預設 90 |
| `member:seq:{tenant}` | UID 序號(`INCR`) | 永久 | | `member:seq:{tenant}` | UID 序號 | 永久 |
Helper 函式見 `domain/redis.go`,**禁止** 在他處字串拼接 key Key helper 在 `domain/redis.go`**禁止** 在他處字串拼接
--- ---
## 生命週期與狀態機 ## 重要流程
```mermaid ### 1. 業務 Email / Phone OTP 驗證logic 編排示範)
stateDiagram-v2
[*] --> unverified: Lifecycle.CreateUnverified<br/>(platform 註冊)
[*] --> active: Provisioning.Ensure*<br/>(OIDC/LDAP/SCIM 首登)
unverified --> active: Activate<br/>(OTP 驗證通過) `internal/logic/member/verify_helper.go` 串多個 atomic
unverified --> deleted: AbortPending<br/>(註冊逾時)
active --> suspended: Suspend(reason)
suspended --> active: Reactivate
active --> deleted: SoftDelete
suspended --> deleted: SoftDelete
deleted --> [*]
```
`transition()` 強制 `from → to`,不符回 `ErrInvalidStatus`
---
## 核心流程時序圖
### 1. 模組裝配 (NewModuleFromParam)
```mermaid
sequenceDiagram
autonumber
participant SVC as svc.NewServiceContext
participant Mod as member.NewModuleFromParam
participant Repo as repository
participant Redis
participant Mongo
SVC->>Mod: ModuleParam{Redis, MongoConf, Config}
Mod->>Repo: NewRedisOTPChallengeStore(redis)
Mod->>Repo: NewRedisVerifyRateStore(redis)
alt MongoConf.Host != ""
Mod->>Repo: NewMemberRepository / NewTenantRepository / NewIdentityRepository
Mod->>Repo: NewMongoTOTPProfileRepository
Repo->>Mongo: ping (lazy)
end
Mod->>Repo: NewRedisUIDGenerator(redis)
Mod->>Mod: MustOTPUseCase / MustVerifyRateUseCase
alt Mongo 就緒
Mod->>Mod: MustProfileUseCase / MustLifecycleUseCase / MustTenantUseCase / MustProvisioningUseCase
end
alt TOTP.SecretKEK != ""
Mod->>Mod: NewAESGCMFromString(KEK)
Mod->>Repo: NewRedisTOTPEnrollStore / NewRedisTOTPReplayStore
Mod->>Mod: MustTOTPUseCase
end
Mod-->>SVC: *Module(7 usecase + 3 repo)
SVC->>SVC: sc.MemberOTP / sc.MemberLifecycle / ...
```
### 2. Tenant 建立
```mermaid
sequenceDiagram
autonumber
participant CLI as cmd/member-seed
participant TenantUC as TenantUseCase
participant Repo as TenantRepository
participant Mongo
CLI->>TenantUC: Create(req{TenantID, Slug, Name, UIDPrefix})
TenantUC->>TenantUC: normalizeUIDPrefix + 長度檢查 (2-4)
TenantUC->>Repo: GetByUIDPrefix(prefix)
Repo->>Mongo: findOne
alt prefix 已存在
TenantUC-->>CLI: ErrAlreadyExist("uid_prefix already exists")
else 不存在
TenantUC->>Repo: Insert(Tenant{Status: active})
Repo->>Mongo: insertOne
TenantUC-->>CLI: TenantDTO
end
```
### 3. Platform 註冊 (auth + member.Lifecycle)
> 屬於 `internal/logic/auth/register_logic.go` 的編排;Member module 只負責 atomic 動作。
```mermaid
sequenceDiagram
autonumber
participant Client
participant RegLogic as logic/auth.RegisterLogic
participant TenantUC as TenantUseCase
participant Zitadel as library/zitadel
participant Lifecycle as LifecycleUseCase
participant OTP as OTPUseCase
participant Notifier
participant Confirm as logic/auth.RegisterConfirmLogic
Client->>RegLogic: POST /auth/register {tenant_slug, email, password}
RegLogic->>TenantUC: ResolveBySlug(slug)
TenantUC-->>RegLogic: TenantDTO
RegLogic->>Zitadel: CreateHumanUser(...)
Zitadel-->>RegLogic: zitadel_user_id
RegLogic->>Lifecycle: CreateUnverified(req{tenant, email, hash, zitadel_user_id})
Lifecycle->>Lifecycle: 取 tenant.UIDPrefix → UIDGenerator.Next
Lifecycle->>Lifecycle: members.Insert(status=unverified)
Lifecycle-->>RegLogic: MemberDTO(uid)
RegLogic->>OTP: Generate(purpose=Register, uid, target=email)
OTP-->>RegLogic: challenge_id, plainCode
RegLogic->>Notifier: Send(VerifyEmail, code)
alt Notifier 失敗
RegLogic->>Lifecycle: AbortPending(uid)
RegLogic-->>Client: 5xx
else 成功
RegLogic-->>Client: {challenge_id, expires_in}
end
Note over Client,Confirm: 使用者收到信
Client->>Confirm: POST /auth/register/confirm {challenge_id, code}
Confirm->>OTP: MatchChallenge(challenge_id, tenant, purpose=Register, RequireUID)
OTP-->>Confirm: OTPChallengeInfo{uid}
Confirm->>OTP: Verify(challenge_id, code, uid, purpose)
OTP-->>Confirm: target(email)
Confirm->>Lifecycle: Activate(tenant, uid) // unverified → active
Confirm-->>Client: JWT (auth 簽發)
```
### 4. Provisioning — OIDC / LDAP / SCIM
外部身份首次登入時透過 `EnsureFromOIDC` upsert,**冪等**(既存即回傳)。
```mermaid
sequenceDiagram
autonumber
participant Logic as logic/auth.LoginSocialCallback
participant Prov as ProvisioningUseCase
participant MR as MemberRepository
participant IR as IdentityRepository
participant TR as TenantRepository
participant UID as UIDGenerator
participant Redis
participant Mongo
Logic->>Prov: EnsureFromOIDC(tenant, zitadel_sub, email, ...)
Prov->>MR: GetByZitadelUserID(tenant, sub)
MR->>Mongo: find
alt 已存在
MR-->>Prov: Member
Prov-->>Logic: MemberDTO (origin=oidc, status=active)
else ErrNotFound
Prov->>TR: GetByTenantID(tenant)
TR-->>Prov: Tenant{UIDPrefix}
Prov->>UID: Next(tenant, prefix)
UID->>Redis: INCR member:seq:{tenant}
UID-->>Prov: "ACME-10000003"
Prov->>MR: Insert(Member{status=active, origin=oidc, zitadel_user_id})
MR->>Mongo: insertOne
alt duplicate(競態)
MR-->>Prov: ErrDuplicateMember
Prov->>MR: GetByZitadelUserID // 再讀一次回傳
end
Prov->>IR: Insert(Identity{zitadel_user_id, uid})
IR->>Mongo: insertOne(忽略 dup)
Prov-->>Logic: MemberDTO
end
```
LDAP / SCIM 同樣模式,額外查 `IdentityRepository.GetByExternalID` 處理沒有 zitadel_sub 的情境。
### 5. 業務 Email / Phone OTP 驗證
`internal/logic/member/verify_helper.go` 編排(`startVerification` + `confirmVerification`),展示 logic 層如何把多個 atomic usecase 串起來。
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
participant Client participant Client
participant Logic as logic/member.startVerification participant Logic as logic/member.startVerification
participant Rate as VerifyRateUseCase participant Rate as VerifyRate
participant OTP as OTPUseCase participant OTP
participant Notif as Notifier participant Notif as Notifier
participant Profile as ProfileUseCase participant Profile
participant Redis
Client->>Logic: POST /me/verifications/email/start {target} Client->>Logic: POST /me/verifications/email/start {target}
Logic->>Rate: AssertResendAllowed(rateKey, cooldown=60s) Logic->>Rate: AssertResendAllowedcooldown
Rate->>Redis: SETNX member:verify:rate:{t}:{u}:business_email Logic->>Rate: AssertDailyAllowed每日上限
alt cooldown 中 Logic->>OTP: Generate(purpose=BusinessEmail, target=email)
Rate-->>Logic: ErrTooManyRequest
Logic-->>Client: 429
end
Logic->>Rate: AssertDailyAllowed(dailyKey, 24h, limit=10)
Rate->>Redis: INCR member:verify:daily:{t}:{u}:business_email
Logic->>OTP: Generate(uid, purpose=BusinessEmail, target=email)
OTP->>Redis: SET member:otp:challenge:{id} (bcrypt hash, TTL=300s)
OTP-->>Logic: challenge_id, plainCode OTP-->>Logic: challenge_id, plainCode
Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in}) Logic->>Notif: Send(VerifyEmail, code)
alt Notifier 失敗 alt Notifier 失敗
Logic->>OTP: Invalidate(challenge_id) Logic->>OTP: Invalidate(challenge_id)
Logic-->>Client: 5xx
else 成功
Logic-->>Client: {challenge_id, expires_in}
end end
Logic-->>Client: {challenge_id, expires_in}
Note over Client,Profile: 使用者收到信 Note over Client,Profile: 使用者收到信
Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code} Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code}
Logic->>OTP: Verify(challenge_id, code, uid, purpose=BusinessEmail) Logic->>OTP: Verify (bcrypt compare、attempts ↑↑↑)
OTP->>Redis: GET + bcrypt compare OTP-->>Logic: target(email)
alt 失敗 Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
OTP->>Redis: INCR attempts Logic-->>Client: 204
alt attempts >= 5
OTP-->>Logic: ErrChallengeLocked
else
OTP-->>Logic: ErrInvalidOTP
end
else 成功
OTP->>Redis: DEL challenge
OTP-->>Logic: target(email)
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
Profile-->>Logic: nil
Logic-->>Client: 204
end
``` ```
**關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門 Key`Verify` 成功後 challenge 立刻刪除(一次性);`Generate` 必先過 `VerifyRate` 兩道閘。
### 6. TOTP 綁定 / Step-up ### 2. TOTP綁定 + step-up + 重放保護)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber autonumber
participant Client participant Client
participant Logic
participant TOTP as TOTPUseCase participant TOTP as TOTPUseCase
participant Profile as TOTPProfileRepository participant Profile as TOTPProfileRepository
participant Enroll as TOTPEnrollStore participant Enroll as TOTPEnrollStore
participant Replay as TOTPReplayStore participant Replay as TOTPReplayStore
participant Cipher as crypto.Cipher (AES-GCM) participant Cipher as crypto.Cipher
Note over Client,Cipher: A. 綁定階段 Note over Client,Cipher: 綁定階段
Client->>Logic: POST /me/totp/enroll Client->>TOTP: StartEnroll(tenant, uid, account)
Logic->>TOTP: StartEnroll(tenant, uid, account) TOTP->>Profile: 必須未 enrolled
TOTP->>Profile: Get → 必須未 enrolled TOTP->>Cipher: Encrypt(secret)
TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte)
TOTP->>Cipher: Encrypt(secret) → cipherBlob
TOTP->>Enroll: Save(cipherBlob, TTL=600s) TOTP->>Enroll: Save(cipherBlob, TTL=600s)
TOTP-->>Logic: {otpauth_url, digits=6, period=30} TOTP-->>Client: {otpauth_url, digits, period}
Logic-->>Client: QR code 資料
Client->>Client: 掃 QR 加入 Authenticator Client->>TOTP: ConfirmEnroll(code)
Client->>Logic: POST /me/totp/enroll/confirm {code} TOTP->>Enroll: Get cipherBlob
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
TOTP->>Enroll: Get → cipherBlob
TOTP->>Cipher: Decrypt → secret TOTP->>Cipher: Decrypt → secret
TOTP->>TOTP: totp.Verify(secret, code, ±window) TOTP->>TOTP: totp.Verify(±window)
alt 驗碼失敗 TOTP->>Profile: Save (Enrolled, SecretCipher, BackupCodesHash)
TOTP-->>Logic: ErrTOTPInvalidCode TOTP-->>Client: plainBackupCodes僅此一次回傳
else 成功
TOTP->>TOTP: 產生 N 個 backup codes + bcrypt hashes
TOTP->>Profile: Save({Enrolled, SecretCipher, BackupCodesHash})
TOTP->>Enroll: Delete (清掉 staged)
TOTP-->>Logic: plainCodes[](僅此一次回傳)
end
Note over Client,Replay: B. 日常 step-up Note over Client,Replay: 日常 step-up
Client->>Logic: 任意敏感操作攜 6 碼 Client->>TOTP: VerifyCode(code)
Logic->>TOTP: VerifyCode(tenant, uid, code) TOTP->>Replay: MarkUsed(timestep) → fresh?
TOTP->>Profile: Get → 必須 enrolled alt 已用過
TOTP->>Cipher: Decrypt(SecretCipher) TOTP-->>Client: ErrTOTPCodeReplay
alt code 長度 = 6
TOTP->>TOTP: totp.Verify(±window) → step
alt OK
TOTP->>Replay: MarkUsed(timestep, TTL=90s) → fresh?
alt 已用過
TOTP-->>Logic: ErrTOTPCodeReplay
else 未用過
TOTP-->>Logic: nil
end
else 失敗
TOTP->>TOTP: fall through to backup code
end
end
alt 嘗試備援碼
loop 每組 hash
TOTP->>TOTP: bcrypt.CompareHashAndPassword
end
alt 命中
TOTP->>Profile: ConsumeBackupCode(hash) (atomic)
TOTP-->>Logic: nil
else 全失敗
TOTP-->>Logic: ErrTOTPInvalidCode
end
end end
``` ```
### 7. UID 生成 ### 3. UID 生成
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
autonumber
participant Caller as Lifecycle / Provisioning
participant Gen as UIDGenerator
participant Redis
Caller->>Gen: Next(tenant, uidPrefix) Caller->>Gen: Next(tenant, uidPrefix)
Gen->>Redis: INCR member:seq:{tenant} Gen->>Redis: INCR member:seq:{tenant}
Redis-->>Gen: seq alt seq == 1首次
alt seq == 1 (首次) Gen->>Redis: INCRBY 9_999_999 → 10_000_000
Note right of Gen: 一次補上起始值<br/>(避開像 ACME-1 這種短 UID)
Gen->>Redis: INCRBY (UIDSequenceStart - 1) = 9_999_999
Redis-->>Gen: 10_000_000
end end
Gen-->>Caller: "{PREFIX}-{seq}" 例:ACME-10000003 Gen-->>Caller: "ACME-10000003"
``` ```
`UIDSequenceStart = 10_000_000`(7 位起跳),`UIDPrefix` 限制 2~4 個大寫字母。 `UIDSequenceStart = 10_000_000`prefix 限 2~4 個大寫字母。
> 平台註冊 + Provisioning OIDC/LDAP/SCIM 詳細時序,見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md)。
--- ---
## Redis Key 命名 ## 設定(`etc/gateway.dev.yaml`
| Helper | 對應 key | 使用者 |
| --- | --- | --- |
| `GetOTPChallengeRedisKey(id)` | `member:otp:challenge:{id}` | `OTPChallengeStore` |
| `GetOTPAttemptsRedisKey(id)` | `member:otp:challenge:{id}:attempts` | `OTPChallengeStore` |
| `GetVerifyRateRedisKey(tenant, uid, kind)` | `member:verify:rate:...` | `VerifyRate` (logic 層) |
| `GetVerifyDailyRedisKey(tenant, uid, kind)` | `member:verify:daily:...` | 同上 |
| `GetTOTPEnrollRedisKey(tenant, uid)` | `member:totp:enroll:...` | `TOTPEnrollStore` |
| `GetTOTPUsedRedisKey(tenant, uid, step)` | `member:totp:used:...` | `TOTPReplayStore` |
| `GetMemberSeqRedisKey(tenant)` | `member:seq:{tenant}` | `UIDGenerator` |
`kind` 通常是 `enum.OTPPurpose` 字串(`business_email`、`business_phone`、`step_up` 等)。
---
## 設定
`etc/gateway.dev.yaml``Member` 區塊:
```yaml ```yaml
Member: Member:
Registration: Registration:
RequireInviteCode: true # 平台註冊是否強制邀請碼 RequireInviteCode: true
TrustSocialEmailVerified: true # OIDC email_verified=true 直接 active TrustSocialEmailVerified: true # OIDC email_verified=true 直接 active
OTP: OTP:
Length: 6 # 驗證碼位數 Length: 6
TTLSeconds: 300 # challenge 存活時間 TTLSeconds: 300
MaxAttempts: 5 # 單 challenge 最大錯誤次數 MaxAttempts: 5
ResendCooldownSeconds: 60 # 重發冷卻 ResendCooldownSeconds: 60
DailyVerifyLimit: 10 # 每日上限 DailyVerifyLimit: 10
TOTP: TOTP:
Issuer: CloudEP Issuer: CloudEP
Algorithm: SHA1 Algorithm: SHA1
Digits: 6 Digits: 6
PeriodSeconds: 30 PeriodSeconds: 30
Window: 1 # ±1 time step 容忍 Window: 1
BackupCodeCount: 10 BackupCodeCount: 10
BackupCodeLength: 12 BackupCodeLength: 12
EnrollTTLSeconds: 600 EnrollTTLSeconds: 600
ReplayTTLSeconds: 90 ReplayTTLSeconds: 90
SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空關閉 TOTP SecretKEK: "" # 32-byte hex (64) 或 base64留空關閉 TOTP
``` ```
**`SecretKEK`** 可改用環境變數 `TOTP_SECRET_KEK`(prod 建議走 KMS / secret manager) **`SecretKEK`** prod 走 env 或 KMS`TOTP_SECRET_KEK`)。
--- ---
## ServiceContext 注入 ## ServiceContext 注入
```go ```go
// internal/svc/service_context.go sc.MemberOTP // 一定有Redis 必填)
sc.MemberOTP // domusecase.OTPUseCase (一定有) sc.MemberVerifyRate // 一定有
sc.MemberVerifyRate // domusecase.VerifyRateUseCase (一定有) sc.MemberProfile // Mongo 啟用後
sc.MemberProfile // domusecase.ProfileUseCase (Mongo 設定後) sc.MemberLifecycle // Mongo 啟用後
sc.MemberLifecycle // domusecase.LifecycleUseCase (Mongo 設定後) sc.MemberTenant // Mongo 啟用後
sc.MemberTenant // domusecase.TenantUseCase (Mongo 設定後) sc.MemberProvisioning // Mongo 啟用後
sc.MemberProvisioning // domusecase.ProvisioningUseCase(Mongo 設定後) sc.MemberTOTP // TOTP.SecretKEK 設定後,否則 nil
sc.MemberTOTP // domusecase.TOTPUseCase (TOTP.SecretKEK 設定後;否則 nil)
``` ```
Logic 層使用前務必檢查可能 `nil` 的欄位:
```go ```go
if sc.MemberTOTP == nil { if sc.MemberTOTP == nil {
return errb.SysNotImplemented("member TOTP not configured") return errb.SysNotImplemented("member TOTP not configured")
@ -577,7 +245,7 @@ if sc.MemberTOTP == nil {
## 測試 ## 測試
### 單元測試 ### 單元
```bash ```bash
go test ./internal/model/member/... -v go test ./internal/model/member/... -v
@ -585,47 +253,40 @@ make check
``` ```
| 檔案 | 覆蓋 | | 檔案 | 覆蓋 |
| --- | --- | |------|------|
| `usecase/otp_usecase_test.go` | Generate/Verify、UID/purpose mismatch、attempts lock | | `usecase/otp_usecase_test.go` | Generate / Verify、purpose mismatch、attempts lock |
| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate | | `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL | | `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
### 本機 API(P4) ### 本機 API
```bash ```bash
make deps-up # docker compose: mongo + redis make deps-up && make mongo-index
make mongo-index # 建索引 make member-seed # 建 dev tenant + member
make member-seed # 建 dev tenant + 一筆 member,輸出 X-Tenant-ID/X-UID headers make run-dev
make run-local # 啟動 gateway
# Profile curl -s -H "Authorization: Bearer $TOKEN" \
curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
http://127.0.0.1:8888/api/v1/members/me | jq http://127.0.0.1:8888/api/v1/members/me | jq
# 業務 email 驗證(start → confirm)
curl -s -X POST -H "Content-Type: application/json" \
-H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
-d '{"target":"you@example.com"}' \
http://127.0.0.1:8888/api/v1/members/me/verifications/email/start | jq
``` ```
完整 API 見 `generate/api/member.api` ### 互動式 TOTP
### 互動式 TOTP(Google Authenticator)
```bash ```bash
make deps-up make totp-test # STEP=flow整套綁定 + 驗碼 + 重放
make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放
make totp-test STEP=status make totp-test STEP=status
make totp-test STEP=disable
``` ```
需在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。 `Member.TOTP.SecretKEK` 已設定。
### E2E
見 [`docs/e2e-testing.md`](../../../docs/e2e-testing.md)`TestMember_*`)。
--- ---
## 設計參考 ## 相關文件
- 詳細領域模型 / 多租戶設計 / B2B Permission 對接:`docs/identity-member-design.md` - [`SDD.md`](./SDD.md) — Member 模組規格書Data Dictionary、完整 API 端點)
- 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1 - [`docs/model.md`](../../../docs/model.md) — Clean Architecture 分層
- 統一錯誤格式(`errb.*`):`internal/library/errors/README.md` - [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) — 跨模組設計
- [`internal/library/errors/README.md`](../../library/errors/README.md) — 錯誤碼

View File

@ -40,7 +40,7 @@ const (
BSONFieldExternalID = "external_id" BSONFieldExternalID = "external_id"
) )
// UID sequence defaults (identity-member-design.md §12). // UID sequence defaults. See internal/model/member/SDD.md §3.4 (UID Generation).
const ( const (
UIDSequenceStart int64 = 10_000_000 UIDSequenceStart int64 = 10_000_000
UIDSequenceBucket int64 = 500 UIDSequenceBucket int64 = 500

View File

@ -7,8 +7,8 @@ import "strings"
// concatenation should be avoided so the layout stays auditable. // concatenation should be avoided so the layout stays auditable.
type RedisKey string type RedisKey string
// Key prefixes for the member module. Layout matches identity-member-design.md // Key prefixes for the member module. Layout matches
// section 14 (Redis Key 命名). // internal/model/member/SDD.md §4.1 (Redis Keys).
const ( const (
OTPChallengeRedisKey RedisKey = "member:otp:challenge" OTPChallengeRedisKey RedisKey = "member:otp:challenge"
VerifyRateRedisKey RedisKey = "member:verify:rate" VerifyRateRedisKey RedisKey = "member:verify:rate"

View File

@ -4,10 +4,10 @@ import "context"
// TOTPUseCase manages business-tier RFC 6238 TOTP enrollment and verification. // TOTPUseCase manages business-tier RFC 6238 TOTP enrollment and verification.
// //
// The contract mirrors identity-member-design.md §5.8: enrollment is split in // The contract mirrors internal/model/member/SDD.md §3.5: enrollment is split
// two steps (start → confirm) so the secret is only committed after the user // in two steps (start → confirm) so the secret is only committed after the
// proves possession; backup codes are returned exactly once on confirmation // user proves possession; backup codes are returned exactly once on
// and replenished via RegenerateBackupCodes. // confirmation and replenished via RegenerateBackupCodes.
type TOTPUseCase interface { type TOTPUseCase interface {
// StartEnroll generates a fresh secret, stashes it in a short-lived cache, // StartEnroll generates a fresh secret, stashes it in a short-lived cache,
// and returns the otpauth URL for QR rendering. Calling it twice replaces // and returns the otpauth URL for QR rendering. Calling it twice replaces

View File

@ -25,8 +25,8 @@ import (
"time" "time"
) )
// Defaults match the configuration documented in identity-member-design.md // Defaults match the configuration documented in internal/model/member/SDD.md
// section 5.8 / etc/gateway.yaml TOTP block. // §3.5 / etc/gateway.yaml TOTP block.
const ( const (
DefaultDigits = 6 DefaultDigits = 6
DefaultPeriod = 30 * time.Second DefaultPeriod = 30 * time.Second

View File

@ -2,6 +2,8 @@
統一對外通知入口Email / SMS支援同步 `Send`、異步 `Enqueue` + Redis 重試 Worker、冪等、配額、DLQ。 統一對外通知入口Email / SMS支援同步 `Send`、異步 `Enqueue` + Redis 重試 Worker、冪等、配額、DLQ。
> 規格書Data Dictionary `notifications` / `notification_dlq`、NotifyKind 一覽、API→ [`SDD.md`](./SDD.md)
--- ---
## 測試(本機) ## 測試(本機)
@ -167,9 +169,9 @@ Email provider 看 `SMTP.Enable` / `SES.Enable`,不是 `Provider: smtp` 字串
## 相關文件 ## 相關文件
- [docs/notification-testing.md](../../../docs/notification-testing.md) — `notify-test` METHOD 速查 - [`docs/notification-testing.md`](../../../docs/notification-testing.md) — `notify-test` METHOD 速查
- [docs/model.md](../../../docs/model.md) — domain 分包、Redis/Mongo 生命週期 - [`docs/model.md`](../../../docs/model.md) — domain 分包、Redis/Mongo 生命週期
- [docs/identity-member-design.md §11](../../../docs/identity-member-design.md#11-notification-module) — 產品設計 - [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §8 — 在跨模組流程中的角色
- [etc/README.md](../../../etc/README.md) — Gateway 設定 - [`etc/README.md`](../../../etc/README.md) — Gateway 設定
- [internal/library/redis/README.md](../../library/redis/README.md) - [`internal/library/redis/README.md`](../../library/redis/README.md)
- [internal/library/mongo/README.md](../../library/mongo/README.md) - [`internal/library/mongo/README.md`](../../library/mongo/README.md)

View File

@ -1,17 +1,21 @@
# Permission Module # Permission 模組
> 本模組提供 Gateway 多租戶 **B2B 自定義 RBAC**:平台級 Permission Catalog + 租戶級 Role / RolePermission / UserRole / RoleMapping搭配 Casbin enforcer 進行 HTTP path/method 授權。設計參考 `docs/identity-member-design.md` §6 / §7.3 / §13。 Gateway 多租戶 **B2B 自定義 RBAC**:平台級 Permission Catalog + 租戶級 Role / RolePermission / UserRole / RoleMapping + Casbin enforcer。
- **規格書**Data Dictionary、API 端點欄位、Casbin model→ [`SDD.md`](./SDD.md)
- **跨模組總覽** → [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §6
- 本 README = 流程圖 + curl + ServiceContext 速查
--- ---
## 0. TL;DR ## TL;DR
```mermaid ```mermaid
flowchart LR flowchart LR
subgraph Platform["平台層 (Platform-wide)"] subgraph Platform["平台層"]
Catalog[Permission Catalog] Catalog[Permission Catalog]
end end
subgraph Tenant["租戶層 (per-tenant)"] subgraph Tenant["租戶層"]
Role[Role] Role[Role]
RP[RolePermission] RP[RolePermission]
UR[UserRole] UR[UserRole]
@ -25,33 +29,32 @@ flowchart LR
Casbin -- Check --> Middleware[CasbinRBAC Middleware] Casbin -- Check --> Middleware[CasbinRBAC Middleware]
``` ```
- Permission **平台 seed 全局**`cmd/permission-seed`,租戶不可新增;只能勾選。 - Permission **平台 seed 全局**`cmd/permission-seed`;租戶不可新增、只能勾選
- Role / RolePermission / UserRole **租戶獨立**;同名 role 可在不同租戶共存。 - Role / RolePermission / UserRole **租戶獨立**;同名 role 跨租戶共存
- Role.Key 一旦建立 **不可改**;外部 IdPZITADEL / LDAP / SCIM以 Key 作對應。 - Role.Key 一旦建立 **不可改**(外部 IdP mapping 直接綁 key
- 多 pod 同步:**Redis Pub/Sub 即時通知 + 5min cron 兜底** - 多 pod 同步:**Redis Pub/Sub 即時 + 5min cron 兜底**
--- ---
## 1. 核心概念 ## 核心概念
| 概念 | 簡述 | 關鍵欄位 | | 概念 | 簡述 | 關鍵欄位 |
|------|------|----------| |------|------|----------|
| **Permission** | 平台級權限節點樹狀dot notation | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy | | **Permission** | 平台級權限節點樹狀dot notation | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy |
| **Role** | 租戶內的角色 | `tenant_id + key` unique`is_system=true` 不可刪 | | **Role** | 租戶內角色 | `(tenant_id, key)` unique`is_system=true` 不可刪 |
| **RolePermission** | Role 勾選了哪些 Permission | 自動補齊 parent permission ID | | **RolePermission** | Role 勾選的 Permission | 自動補齊 parent permission |
| **UserRole** | 使用者被指派的角色(多角色) | `source` 區分 manual / zitadel / ldap / scim | | **UserRole** | 使用者被指派的角色 | `source` ∈ {manual / zitadel / ldap / scim} |
| **RoleMapping** | 外部 group/role → 內部 Role.Key | SyncFromX 用來翻譯 IdP claims | | **RoleMapping** | 外部 group → 內部 Role.Key | SyncFromX 用來翻譯 IdP claims |
| **Casbin Policy** | 物化後的授權規則Redis Set | `(tenant, role, path, methods, name)` | | **Casbin Rule** | 物化後規則 | `[tenant, role.key, http_path, http_methods, perm.name]` |
### 1.1 Permission Tree 範例 ### Permission Tree 範例
``` ```
member.info.management ← 分類(無 HTTP member.info.management ← 分類(無 HTTP
├── member.basic.info ← 二級分類 ├── member.basic.info ← 二級分類
│ ├── member.info.select GET /api/v1/members/me │ ├── member.info.select GET /api/v1/members/me
│ └── member.info.update PATCH /api/v1/members/me │ └── member.info.update PATCH /api/v1/members/me
├── member.admin.list GET /api/v1/members └── member.admin.list GET /api/v1/members
└── member.admin.read GET /api/v1/members/:uid
permission.role.management ← 分類 permission.role.management ← 分類
├── permission.role.read GET /api/v1/permissions/roles ├── permission.role.read GET /api/v1/permissions/roles
@ -59,367 +62,128 @@ permission.role.management ← 分類
└── permission.assign.write POST/DELETE /api/v1/permissions/users/*/roles* └── permission.assign.write POST/DELETE /api/v1/permissions/users/*/roles*
``` ```
> 分類節點(無 `http_path`**不會**寫入 Casbin policy它們只是 UI 樹狀渲染與 parent closure 用。 > 分類節點(無 `http_path`**不會**寫入 Casbin policy只是 UI 樹狀渲染 + parent closure 用。
--- ---
## 2. 目錄結構 ## 目錄結構
``` ```
internal/model/permission/ internal/model/permission/
├── README.md # 本文件 ├── README.md
├── config/ ├── config/ # CasbinConfig / CacheConfig / ReloadConfig
│ └── config.go # CasbinConfig / CacheConfig / ReloadConfig
├── domain/ ├── domain/
│ ├── const.go # BSON 欄位 / Casbin / Role.Key 規則 │ ├── const.go # BSON 欄位、Casbin / Role.Key 規則
│ ├── errors.go # 模組共用 sentinel errors │ ├── errors.go
│ ├── redis.go # Redis key helpers (casbin / user_roles / role_perms) │ ├── redis.go # casbin / user_roles / role_perms key
│ ├── entity/ │ ├── entity/ # Permission / Role / RolePermission / UserRole / RoleMapping
│ │ ├── permission.go # Permission catalog node │ ├── enum/ # Status / PermissionType / RoleSource
│ │ ├── role.go │ ├── repository/ # 5 個 repo 介面 + Casbin adapter port
│ │ ├── role_permission.go │ └── usecase/ # 7 個 usecase 介面 + DTO
│ │ ├── user_role.go ├── repository/ # Mongo + Rediscasbin policy Set
│ │ └── role_mapping.go ├── usecase/ # 7 個 atomic含 permission_tree / rbac
│ ├── enum/
│ │ ├── status.go # open / close + Permissions map
│ │ ├── permission_type.go # backend_user / frontend_user
│ │ └── role_source.go # manual / zitadel / ldap / scim
│ ├── repository/ # 介面(+ Casbin adapter port
│ │ ├── permission.go
│ │ ├── role.go
│ │ ├── role_permission.go
│ │ ├── user_role.go
│ │ ├── role_mapping.go
│ │ └── casbin_adapter.go
│ └── usecase/ # 介面 + DTO
│ ├── permission.go
│ ├── role.go
│ ├── role_permission.go
│ ├── user_role.go
│ ├── role_mapping.go
│ ├── rbac.go
│ └── authorization_query.go
├── repository/ # Mongo + Redis 實作
│ ├── index.go # EnsureMongoIndexes + bsonOpSet
│ ├── permission_mongo.go
│ ├── role_mongo.go
│ ├── role_permission_mongo.go
│ ├── user_role_mongo.go
│ ├── role_mapping_mongo.go
│ └── casbin_redis.go # tenant-scoped policy Redis Set
├── usecase/ # atomic primitives (7)
│ ├── module.go # NewModuleFromParam
│ ├── errors.go # wrapRepoErr → errs.For(code.Permission)
│ ├── permission_tree.go # buildTree / filterOpenNodes / parent closure
│ ├── permission_usecase.go
│ ├── role_usecase.go
│ ├── role_permission_usecase.go
│ ├── user_role_usecase.go
│ ├── role_mapping_usecase.go
│ ├── authorization_query_usecase.go
│ └── rbac_usecase.go # Casbin enforcer + LoadPolicy + Pub/Sub reload
└── seed/ └── seed/
├── catalog.go # embed + Apply + DefaultSystemRoles ├── catalog.go # embed + Apply + DefaultSystemRoles
└── catalog.json # 平台 seed 資料 └── catalog.json # 平台 seed
``` ```
--- ---
## 3. 模組依賴 ## 7 個 UseCase
```mermaid | UseCase | 主要方法 |
flowchart TD |---------|---------|
Logic[logic/permission] --> SVC[svc.ServiceContext] | `PermissionUseCase` | `GetCatalogTree` / `List` / `UpsertCatalog` / `UpdateStatus` |
SVC --> AuthQ[AuthorizationQueryUseCase] | `RoleUseCase` | `Create` / `Get` / `List` / `Update` / `Delete` |
SVC --> Perm[PermissionUseCase] | `RolePermissionUseCase` | `List` / `Replace`(含 parent closure + Pub/Sub reload |
SVC --> Role[RoleUseCase] | `UserRoleUseCase` | `Assign` / `Revoke` / `List` / `ReplaceForSource` |
SVC --> RolePerm[RolePermissionUseCase] | `RoleMappingUseCase` | `Upsert` / `Delete` / `GetByExternal` / `List` |
SVC --> UserRole[UserRoleUseCase] | `AuthorizationQueryUseCase` | `Me` |
SVC --> Mapping[RoleMappingUseCase] | `RBACUseCase` | `Check` / `LoadPolicy` / `LoadAllPolicies` / `BroadcastReload` / Pub/Sub 訂閱 |
SVC --> RBAC[RBACUseCase]
AuthQ --> RoleR[(roles)]
AuthQ --> PermR[(permissions)]
AuthQ --> RPR[(role_permissions)]
AuthQ --> URR[(user_roles)]
Perm --> PermR
Role --> RoleR
Role --> URR
RolePerm --> RPR
RolePerm --> RoleR
RolePerm --> PermR
UserRole --> URR
UserRole --> RoleR
Mapping --> RMR[(role_mappings)]
Mapping --> RoleR
RBAC --> RoleR
RBAC --> PermR
RBAC --> RPR
RBAC --> URR
RBAC --> Adapter[Casbin Redis Adapter]
Adapter --> Redis[(Redis)]
RBAC --> Pub[Redis Pub/Sub]
```
--- ---
## 4. UseCase 介面7 個) ## 資料儲存
| UseCase | 主要方法 | 注入 | ### MongoDB
|---------|----------|------|
| `PermissionUseCase` | `GetCatalogTree` / `List` / `UpsertCatalog` / `UpdateStatus` | PermissionRepository |
| `RoleUseCase` | `Create` / `Get` / `List` / `Update` / `Delete` | Role + RolePermission + UserRole |
| `RolePermissionUseCase` | `List` / `Replace` | Role + Permission + RolePermission + Reloader |
| `UserRoleUseCase` | `Assign` / `Revoke` / `List` / `ReplaceForSource` | Role + UserRole + Reloader |
| `RoleMappingUseCase` | `Upsert` / `Delete` / `GetByExternal` / `List` | Role + RoleMapping |
| `AuthorizationQueryUseCase` | `Me` | Role + Permission + RolePermission + UserRole |
| `RBACUseCase` | `Check` / `LoadPolicy` / `LoadAllPolicies` / `BroadcastReload` / `Start/StopReloadSubscriber` | All repos + Redis |
--- | Collection | 索引 |
|------------|------|
| `permissions` | `name` uniq、`parent`、`status`、`type` |
| `roles` | `(tenant_id, key)` uniq、`(tenant_id, is_system)` |
| `role_permissions` | `(tenant_id, role_id, permission_id)` uniq、`(tenant_id, permission_id)` |
| `user_roles` | `(tenant_id, uid, role_id)` uniq、`(tenant_id, role_id)`、`(tenant_id, uid, source)` |
| `role_mappings` | `(tenant_id, external_source, external_key)` uniq、`(tenant_id, internal_role_id)` |
## 5. 資料儲存 啟動建索引:`permrepo.EnsureMongoIndexes`(已掛在 `cmd/mongo-index`)。
### 5.1 MongoDB ### Redis
| Collection | 索引 | 用途 |
|------------|------|------|
| `permissions` | `name`(uniq) / `parent` / `status` / `type` | 平台 Permission Catalog樹狀 |
| `roles` | `(tenant_id, key)`(uniq) / `(tenant_id, is_system)` | 租戶角色 |
| `role_permissions` | `(tenant_id, role_id, permission_id)`(uniq) / `(tenant_id, permission_id)` | Role↔Permission 多對多 |
| `user_roles` | `(tenant_id, uid, role_id)`(uniq) / `(tenant_id, role_id)` / `(tenant_id, uid, source)` | User↔Role 多對多 |
| `role_mappings` | `(tenant_id, external_source, external_key)`(uniq) / `(tenant_id, internal_role_id)` | 外部 group → 內部 Role |
啟動時呼叫 `permrepo.EnsureMongoIndexes(ctx, &c.Mongo)`(已掛在 `cmd/mongo-index`)。
### 5.2 Redis Key
| Key | 內容 | TTL | 由誰寫 | | Key | 內容 | TTL | 由誰寫 |
|-----|------|-----|--------| |-----|------|-----|--------|
| `permission:casbin:rules:{tenant_id}` | Set of JSON-encoded `[]string` rules | 永久 | `RBACUseCase.LoadPolicy` / `BroadcastReload` | | `permission:casbin:rules:{tenant_id}` | Set of JSON Casbin rules | 永久 | `RBAC.LoadPolicy` / `BroadcastReload` |
| `perm:user_roles:{tenant_id}:{uid}` | List of role keys讀取快取預留 | `Cache.UserRolesTTLSeconds` | 預留 | | `perm:user_roles:{tenant_id}:{uid}` | role keys 快取(預留) | `Cache.UserRolesTTLSeconds` | — |
| `perm:role_perms:{tenant_id}:{role_id}` | List of permission names預留 | `Cache.RolePermsTTLSeconds` | 預留 | | `perm:role_perms:{tenant_id}:{role_id}` | permission names 快取(預留) | `Cache.RolePermsTTLSeconds` | — |
| `permission:tree:open` | 序列化的全局 open tree預留 | `Cache.CatalogTTLSeconds` | 預留 | | (channel) `casbin:reload` | Pub/Sub payload `{tenant_id, ts}` | — | `RBAC.BroadcastReload` |
| (channel) `casbin:reload` | Pub/Sub payload `{tenant_id, ts}` | — | `RBACUseCase.BroadcastReload` |
> Redis Set + JSON 編碼是為了讓 SaveAll 用 pipelined `DEL + SADD` 一致性更新Pub/Sub 走獨立 go-redis clientgo-zero 沒有 Subscribe詳見 `internal/library/redis/pubsub.go` > Redis Set + JSON 是為了 SaveAll 用 pipelined `DEL + SADD` 原子更新Pub/Sub 走獨立 go-redis clientgo-zero 沒包 Subscribe且 Subscribe 會佔住 conn
--- ---
## 6. 核心流程時序圖 ## 關鍵流程
### 6.1 NewModuleFromParam — 模組組裝 ### 1. RolePermission 全量取代PUT /roles/:id/permissions
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Boot as svc.NewServiceContext API->>UC: Replace(tenantID, roleID, ids)
participant Mod as permission.NewModuleFromParam UC->>Roles: GetByIDtenant check
participant Cfg as config.Defaults() UC->>Perms: GetAllcatalog 全表)
participant Repo as Mongo Repos (5) UC->>UC: ids ⊆ catalog?
participant Casbin as RBACUseCase UC->>UC: getFullParentPermissionIDs(ids)
participant Redis as PolicyAdapter UC->>RP: SetForRoleDeleteMany + InsertMany 原子)
Boot->>Mod: FactoryParam{MongoConf, Redis, Config}
Mod->>Cfg: cfg = Config.Defaults()
Mod->>Repo: NewPermission/Role/.../RoleMapping Repository
Note over Mod: 若已注入 repo測試跳過
alt cfg.Casbin.Enabled && Redis 有
Mod->>Casbin: NewRBACUseCase(repos+Redis)
Casbin-->>Mod: rbacUC
Mod->>Redis: RedisAdapterFactory = NewCasbinRedisAdapter
Mod->>Mod: reloader = rbacUC.BroadcastReload
else 無 Redis 或 Disabled
Mod->>Mod: rbacUC = nilCheck 永遠 deny
end
Mod->>Mod: New {Permission, Role, RolePermission, UserRole, RoleMapping, AuthorizationQuery}
Mod-->>Boot: *Module7 usecases + 5 repos
```
### 6.2 Permission Catalog Seed
```mermaid
sequenceDiagram
participant CLI as cmd/permission-seed
participant Cfg as config.Mongo
participant Idx as permrepo.EnsureMongoIndexes
participant Seed as seed.Apply
participant Cat as Permissions
participant Roles as Roles + RolePermissions
CLI->>Cfg: load -f etc/gateway.dev.yaml
CLI->>Idx: 建立 5 collections 索引
CLI->>Seed: Apply(perms, roles, rolePerms, opts)
alt SkipCatalog == false
Seed->>Cat: 第一輪 UpsertByName不含 parent
Seed->>Cat: GetAll → 建 name→ID index
Seed->>Cat: 第二輪 UpsertByName補 parent ID
end
loop opts.TenantIDs
Seed->>Roles: GetByKey or Insert is_system role
Seed->>Roles: SetForRole(roleID, [permIDs]) ← 全量取代
end
Seed-->>CLI: Report{ catalog, roles, role_perms }
CLI-->>CLI: stdout summary
```
> 預設 5 個 system role`tenant_owner` / `tenant_admin` / `member_manager` / `member` / `viewer`,定義於 `seed/catalog.go::DefaultSystemRoles`
### 6.3 Role 建立 / 更新 / 刪除
```mermaid
sequenceDiagram
participant API as POST/PATCH/DELETE /permissions/roles
participant Logic as logic.permission.*
participant UC as RoleUseCase
participant Repo as RoleRepository
participant URR as UserRoleRepository
API->>Logic: req + actor (tenant_id, uid)
Logic->>UC: Create / Update / Delete
alt Create
UC->>UC: validateRoleKey^[a-z][a-z0-9._-]+$、不可 system./platform_
UC->>Repo: Insert(role) ← unique (tenant_id, key)
else Update
UC->>Repo: GetByID
UC->>UC: 阻擋 is_system 改 status
UC->>Repo: FindOneAndUpdate
else Delete
UC->>Repo: GetByID
UC->>UC: 阻擋 is_system
UC->>URR: ListByRole仍有指派 → 拒絕)
UC->>Repo: DeleteByRole(role_perms)
UC->>Repo: Delete(role)
end
UC-->>Logic: role
Logic-->>API: types.RoleData
```
### 6.4 RolePermission 全量取代PUT /roles/:id/permissions
```mermaid
sequenceDiagram
participant API as PUT /permissions/roles/:id/permissions
participant Logic as logic.replaceRolePermissions
participant UC as RolePermissionUseCase
participant Roles as RoleRepository
participant Perms as PermissionRepository
participant RP as RolePermissionRepository
participant RBAC as RBACUseCase
API->>Logic: req{ID, PermissionIDs}
Logic->>UC: Replace(tenantID, roleID, ids)
UC->>Roles: GetByID驗證 tenant 一致)
UC->>Perms: GetAll拿到 catalog 全表)
UC->>UC: 檢查 ids ⊆ catalog
UC->>UC: getFullParentPermissionIDs(ids, all)
UC->>RP: SetForRole(tenantID, roleID, closure)
Note over RP: DeleteMany + InsertMany 原子化
UC->>RBAC: BroadcastReload(tenantID) UC->>RBAC: BroadcastReload(tenantID)
RBAC-->>UC: okfire-and-forget UC-->>API: nil
UC-->>Logic: nil
Logic-->>API: 200 OK
``` ```
### 6.5 UserRole 指派 / 撤銷 ### 2. SyncFromX外部 IdP 同步)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant API as POST /permissions/users/:uid/roles Sync->>Map: GetByExternal(tenant, source, externalKey)
participant UC as UserRoleUseCase
participant Roles as RoleRepository
participant URR as UserRoleRepository
participant RBAC as RBACUseCase
API->>UC: Assign{tenant, uid, role_id, source=manual}
UC->>Roles: GetByID (tenant scope check)
UC->>URR: Insert(unique tenant+uid+role)
UC->>RBAC: BroadcastReload(tenant)
UC-->>API: UserRole
```
### 6.6 SyncFromX 流程(外部 IdP 來源同步)
```mermaid
sequenceDiagram
participant Sync as auth/provisioning
participant UC as UserRoleUseCase
participant Map as RoleMappingUseCase
participant Roles as RoleRepository
participant URR as UserRoleRepository
participant RBAC as RBACUseCase
Sync->>Map: GetByExternal(tenant, source=zitadel, externalKey)
Map-->>Sync: RoleMapping(internal_role_key) Map-->>Sync: RoleMapping(internal_role_key)
Note over Sync: 收齊 IdP 端所有 roles → keys Note over Sync: 收齊 IdP 端 → keys
Sync->>UC: ReplaceForSource(tenant, uid, source=zitadel, [roleKeys]) Sync->>UC: ReplaceForSource(tenant, uid, source, [roleKeys])
UC->>UC: 阻擋 source==manual防誤洗 UC->>UC: 阻擋 source==manual防誤洗
loop key in roleKeys UC->>URR: DeleteMany source + BulkInsert
UC->>Roles: GetByKey (skip 不存在的)
end
UC->>URR: ReplaceForSource(tenant, uid, source, [roleIDs])
Note over URR: DeleteMany source=zitadel + BulkInsert<br/>※ source=manual 紀錄不動
UC->>RBAC: BroadcastReload(tenant) UC->>RBAC: BroadcastReload(tenant)
``` ```
### 6.7 LoadPolicyCasbin 規則載入) ### 3. LoadPolicy + Check
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Trigger as Replace / Reload / Boot
participant RBAC as RBACUseCase
participant Roles as RoleRepository
participant RP as RolePermissionRepository
participant Perms as PermissionRepository
participant Enf as casbin.SyncedEnforcer
participant Adp as Redis Adapter
Trigger->>RBAC: LoadPolicy(tenantID) Trigger->>RBAC: LoadPolicy(tenantID)
RBAC->>Roles: ListByTenant RBAC->>Roles: ListByTenant
RBAC->>RP: ListByRoles(roleIDs) RBAC->>RP: ListByRoles(roleIDs)
RBAC->>Perms: GetByIDs(unique perm ids) RBAC->>Perms: GetByIDsunique perm ids
RBAC->>RBAC: 過濾 IsLeaf() && Status=open RBAC->>RBAC: 過濾 IsLeaf() && Status=open
RBAC->>RBAC: rules = [tenant, role.key, http_path, http_methods, perm.name] RBAC->>Enforcer: ClearPolicy + AddPolicies
RBAC->>Enf: ClearPolicy + AddPolicies RBAC->>Adapter: SaveAll → Redis pipelined DEL+SADD
RBAC->>Adp: SaveAll(tenant, rules) ← Redis pipelined DEL+SADD
RBAC-->>Trigger: nil
```
### 6.8 Check授權檢查
```mermaid
sequenceDiagram
participant MW as middleware.CasbinRBAC
participant Logic as ActorFromContext
participant RBAC as RBACUseCase
participant URR as UserRoleRepository
participant Roles as RoleRepository
participant Enf as casbin.SyncedEnforcer
MW->>Logic: actor (tenant, uid)
MW->>RBAC: Check{tenant, uid, path, method} MW->>RBAC: Check{tenant, uid, path, method}
RBAC->>RBAC: enforcerFor(tenant)lazy clone model + AddPolicies RBAC->>URR: ListByUser → ListByTenantAndIDs過濾 status=open
RBAC->>URR: ListByUser(tenant, uid)
RBAC->>Roles: ListByTenantAndIDs過濾 status=open
loop role in rolesany-allow loop role in rolesany-allow
RBAC->>Enf: EnforceEx(tenant, role.key, path, method) RBAC->>Enforcer: EnforceEx
alt allow
RBAC-->>MW: CheckResult{Allow=true, MatchedRoleKey, MatchedPolicyRow}
end
end end
MW->>MW: result.Allow ? next : 403 (errs.AuthForbidden) RBAC-->>MW: Allow / Deny403
``` ```
### 6.9 Pub/Sub 多 Pod Reload ### 4. Pub/Sub 多 Pod Reload
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant PodA as Pod A (Replace) PodA->>PodA: RolePermission.Replace + LoadPolicy
participant Redis
participant PodB as Pod B (Subscribe)
participant PodC as Pod C (Subscribe)
PodA->>PodA: RolePermission.Replace + LoadPolicy本地
PodA->>Redis: PUBLISH casbin:reload {tenant, ts} PodA->>Redis: PUBLISH casbin:reload {tenant, ts}
Redis-->>PodB: 推 message Redis-->>PodB: 推 message
Redis-->>PodC: 推 message Redis-->>PodC: 推 message
@ -428,37 +192,11 @@ sequenceDiagram
Note over PodB,PodC: 2-3ms 內三個 pod 同步 Note over PodB,PodC: 2-3ms 內三個 pod 同步
``` ```
> 兜底:每個 pod 可定時跑 `LoadAllPolicies`5min cron未在本模組內排程建議 svc 層或 cron-worker 觸發)。掃 Redis `permission:casbin:rules:*` key 推導 tenant 列表。 > 兜底:每個 pod 可定時 `LoadAllPolicies`5min cron建議 svc 層或 cron-worker 觸發;本模組不內建)。
### 6.10 GET /permissions/me前端選單渲染
```mermaid
sequenceDiagram
participant Front as Frontend
participant API as GET /permissions/me
participant UC as AuthorizationQueryUseCase
participant URR as UserRoleRepository
participant Roles as RoleRepository
participant RP as RolePermissionRepository
participant Perms as PermissionRepository
Front->>API: Bearer JWT
API->>UC: Me(tenant, uid, includeTree)
UC->>URR: ListByUser
UC->>Roles: ListByTenantAndIDs過濾 status=open
UC->>RP: ListByRoles(roleIDs)
UC->>Perms: GetByIDs(unique perm ids)
UC->>UC: permission map = name→status
alt includeTree
UC->>UC: buildPermissionTree + filterOpenNodes
end
UC-->>API: { uid, tenant_id, roles, permissions, tree? }
API-->>Front: 200 OK
```
--- ---
## 7. Casbin 模型(`etc/rbac.conf` ## Casbin 模型(`etc/rbac.conf`
```ini ```ini
[request_definition] [request_definition]
@ -474,61 +212,59 @@ e = some(where (p.eft == allow))
m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods)
``` ```
- `keyMatch2`:支援 `/api/v1/members/*` 萬用 path - `keyMatch2`:支援 `/api/v1/members/*`
- `regexMatch``GET|POST|PATCH` 多 method 同 policy - `regexMatch``GET|POST|PATCH` 多 method 同 policy
- 平台 Admin bypass 不寫 matcher由 middleware 預檢(保留 audit - 平台 Admin bypass 不寫 matcher由 middleware 預檢(保留 audit
--- ---
## 8. ServiceContext 注入 ## ServiceContext 注入
```go ```go
sc.PermissionCatalog // Permission catalog reader (tree / list / status) sc.PermissionCatalog // PermissionUseCase
sc.PermissionRole // Role CRUD(含 system role 防呆) sc.PermissionRole // RoleUseCase(含 system role 防呆)
sc.PermissionRolePermission // Replace含 parent closure sc.PermissionRolePermission // RolePermissionUseCase
sc.PermissionUserRole // Assign / Revoke / ReplaceForSource sc.PermissionUserRole // UserRoleUseCase
sc.PermissionRoleMapping // 外部 group → Role.Key sc.PermissionRoleMapping // RoleMappingUseCase
sc.PermissionAuthQuery // GET /me 用 sc.PermissionAuthQuery // AuthorizationQueryUseCase
sc.PermissionRBAC // Casbin enforcerMongo+Redis 全到位才有) sc.PermissionRBAC // RBACUseCaseMongo+Redis 全到位才有)
sc.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用 sc.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用
``` ```
未啟用 Casbin 時 `PermissionRBAC == nil``Check()` 永遠 denymiddleware 會拒絕所有請求(除非 `AllowMissingActor=true` 未啟用 Casbin 時 `PermissionRBAC == nil``Check()` 永遠 denymiddleware `AllowMissingActor` 決定放行或拒絕
--- ---
## 9. HTTP API前綴 `/api/v1/permissions` ## HTTP API`/api/v1/permissions`
| Method | Path | Handler | 說明 | | Method | Path | Middleware | 說明 |
|--------|------|---------|------| |--------|------|------------|------|
| GET | `/catalog` | `getPermissionCatalog` | 全局 Catalogtree=true 取樹狀) | | GET | `/catalog` | AuthJWT | 全局 Catalogtree/list |
| GET | `/me` | `getMePermissions` | 當前 user 的 role / permission map | | GET | `/me` | AuthJWT | 當前 user roles + permissions |
| GET | `/roles` | `listRoles` | 租戶角色清單 | | GET | `/roles` | AuthJWT+Casbin | 租戶角色清單 |
| POST | `/roles` | `createRole` | 建立角色key 不可改) | | POST | `/roles` | AuthJWT+Casbin | 建立角色 |
| PATCH | `/roles/:id` | `updateRole` | 更新 display_name / statussystem role 限制) | | PATCH | `/roles/:id` | AuthJWT+Casbin | 更新 display_name / status |
| DELETE | `/roles/:id` | `deleteRole` | 刪角色system / 仍有指派 → 拒絕) | | DELETE | `/roles/:id` | AuthJWT+Casbin | 刪角色system / 仍有指派 → 拒絕) |
| GET | `/roles/:id/permissions` | `getRolePermissions` | 角色目前的 permission 集合 | | GET | `/roles/:id/permissions` | AuthJWT+Casbin | 角色 permission 集合 |
| PUT | `/roles/:id/permissions` | `replaceRolePermissions` | 全量取代 + 補 parent + Pub/Sub reload | | PUT | `/roles/:id/permissions` | AuthJWT+Casbin | 全量取代 + parent closure + reload |
| GET | `/users/:uid/roles` | `listUserRoles` | 使用者目前指派的 role | | GET | `/users/:uid/roles` | AuthJWT+Casbin | 使用者 role |
| POST | `/users/:uid/roles` | `assignUserRole` | 指派角色source 預設 manual | | POST | `/users/:uid/roles` | AuthJWT+Casbin | 指派 |
| DELETE | `/users/:uid/roles/:role_id` | `revokeUserRole` | 撤銷單一角色 | | DELETE | `/users/:uid/roles/:role_id` | AuthJWT+Casbin | 撤銷 |
| GET | `/role-mappings` | `listRoleMappings` | 外部映射列表(分頁) | | GET / PUT / DELETE | `/role-mappings` | AuthJWT+Casbin | 外部映射 CRUD |
| PUT | `/role-mappings` | `upsertRoleMapping` | Upsert 外部 group → Role.Key | | POST | `/policy/reload` | AuthJWT+Casbin | 強制重載(單 tenant 或 `*` |
| DELETE | `/role-mappings` | `deleteRoleMapping` | 刪除外部映射 |
| POST | `/policy/reload` | `reloadPolicy` | 強制重載(單租戶或 `*` |
完整錯誤碼註解參見 `generate/api/permission.api`,由 `make gen-doc` 出 OpenAPI 完整 schema 見 `generate/api/permission.api`
--- ---
## 10. 設定範例`etc/gateway.dev.example.yaml` ## 設定(`etc/gateway.dev.yaml`
```yaml ```yaml
Permission: Permission:
Casbin: Casbin:
Enabled: false # 預設關閉,啟用後 RBAC enforcement 生效 Enabled: false # 預設關閉
ModelPath: etc/rbac.conf ModelPath: etc/rbac.conf
PolicyAdapter: auto # auto / redis / mongo PolicyAdapter: auto # auto / redis / mongo
Cache: Cache:
UserRolesTTLSeconds: 300 UserRolesTTLSeconds: 300
RolePermsTTLSeconds: 300 RolePermsTTLSeconds: 300
@ -541,103 +277,57 @@ Permission:
--- ---
## 11. CLI / 操作指南 ## CLI / 操作
```bash ```bash
# 1) 建索引 # 建索引
make mongo-index make mongo-index
# 2) 撰寫 / 修改 catalog # Catalog seed全平台
$EDITOR internal/model/permission/seed/catalog.json
# 3) 全平台 seed catalog不為任何 tenant 建 role
go run ./cmd/permission-seed -f etc/gateway.dev.yaml go run ./cmd/permission-seed -f etc/gateway.dev.yaml
# 4) 同時為 dev tenant seed 5 個 system role # Catalog + 為 tenant seed 5 個 system role
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001
# 5) 多租戶 # 只 reseed tenant rolecatalog 已存在)
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001,TEN-100002
# 6) 只 reseed tenant rolecatalog 已存在)
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog
# 7) 強制全 pod 重載 policyHTTP # 強制全 pod 重載 policy
curl -X POST http://localhost:8888/api/v1/permissions/policy/reload \ curl -X POST http://localhost:8888/api/v1/permissions/policy/reload \
-H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-ID: TEN-100001" -H "X-UID: TEN-100001-OWNER" \
-d '{"tenant_id": "*"}' -d '{"tenant_id": "*"}'
``` ```
--- 預設 5 個 system role`tenant_owner` / `tenant_admin` / `member_manager` / `member` / `viewer``seed/catalog.go::DefaultSystemRoles`)。
## 12. 中介層middleware/casbin_rbac.go
**現況:** middleware 已寫好,但 **尚未掛入 routes.go**(避免影響現有 dev 模式)。要啟用:
```go
import perm "gateway/internal/middleware"
server.AddRoutes(routes,
rest.WithMiddlewares(
[]rest.Middleware{
middleware.CloudEPJWT(serverCtx.AuthToken), // 已存在
middleware.CasbinRBAC(serverCtx.PermissionRBAC, middleware.CasbinRBACOptions{
AllowMissingActor: false,
SkipPaths: map[string]struct{}{
"/api/v1/health": {},
},
}),
}...,
),
rest.WithPrefix("/api/v1/members"),
)
```
要先:
1. 跑 seed CLI 把 catalog + system role 建好
2. 為平台 admin tenant 建 `platform_super_admin` role + bypass allowlist
3. 開啟 `Permission.Casbin.Enabled = true`
4. 設好 `Permission.Reload.Channel`(多 pod 才需要)
--- ---
## 13. 測試 ## 設計約束(速查)
```bash
# 全模組 unit test
go test ./internal/model/permission/...
# 含整合(需要 Mongo + Redis 在 docker compose 起著)
make deps-up
go test -tags=integration ./internal/model/permission/...
```
---
## 14. 設計權衡 / 注意事項
| 議題 | 決策 | 原因 | | 議題 | 決策 | 原因 |
|------|------|------| |------|------|------|
| Permission `name` 改名 | **禁止** | 被 RolePermission、UI i18n、Casbin policy.name 引用;廢棄走 `status=close` 然後新建 | | Permission `name` 改名 | 禁止 | 被 RolePermission / Casbin policy.name 引用;廢棄改 `status=close` |
| Role `key` 改名 | **禁止** | 外部 IdP mapping 直接綁 key改名會切斷映射 | | Role `key` 改名 | 禁止 | 外部 IdP mapping 直接綁 key |
| `is_system` role 刪除 | 拒絕 | 平台預設角色保留 | | `is_system` 刪除 / 改 status | 拒絕 | 平台預設角色保留 |
| `is_system` role 改 status | 拒絕 | 維持平台預期行為 |
| `manual` source ReplaceForSource | 拒絕 | 防 SyncFromX 誤洗手動指派 | | `manual` source ReplaceForSource | 拒絕 | 防 SyncFromX 誤洗手動指派 |
| Permission 有 `*` 萬用 path | 不建議裸 `*`;至少帶資源根 | 防 keyMatch2 貪婪命中跨資源 | | 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時 + reboot 不漏 |
| Casbin 多 enforcer | 一 tenant 一個 enforcerlazy 建 | 比一個 enforcer + filtered policy 簡單,且記憶體可預測 | | Pub/Sub client | 獨立 go-redis | go-zero 沒包 Subscribe |
| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時通知 + reboot 不漏 | | Catalog 改動 | seed CLIidempotent | catalog.json 是 SoT |
| Pub/Sub client | 獨立 go-redis不走 go-zero pool | go-zero 沒包 Subscribe且 Subscribe 會佔住 conn |
| Permission Catalog 改動 | seed CLI 即可idempotent | UI 端不直接改 catalogseed JSON 是 SoT |
--- ---
## 15. 後續工作 ## 測試
| 項目 | 預估 | ```bash
|------|------| # 單元
| Platform admin allowlist + audit log | 後續 | go test ./internal/model/permission/...
| RoleMapping 用 SyncFromX 落地Zitadel / LDAP / SCIM| 隨對應 SyncFromX usecase 推進 |
| Policy reload cron worker5 min | 取自 svc 啟動 ticker | # 整合(需 Mongo + Redis
| Role permission 編輯 UI不在 Gateway 內,由前端取資) | 前端 | make deps-up
| 細粒度欄位過濾(`.plain_code` 變體) | logic 層額外查 sub-permission | go test -tags=integration ./internal/model/permission/...
# E2E含 Casbin enforcement
make e2e-casbin
```
E2E 細節:[`docs/e2e-testing.md`](../../../docs/e2e-testing.md)。

View File

@ -57,7 +57,7 @@ const (
PolicyReloadAllToken = "*" PolicyReloadAllToken = "*"
) )
// Role.Key constraints (identity-member-design.md §6.5). // Role.Key constraints. See internal/model/permission/SDD.md §3.3 (RBAC Model).
const ( const (
RoleKeyMinLength = 2 RoleKeyMinLength = 2
RoleKeyMaxLength = 64 RoleKeyMaxLength = 64

View File

@ -8,7 +8,7 @@ import "strings"
type RedisKey string type RedisKey string
// Key prefixes for the permission module. Layout matches // Key prefixes for the permission module. Layout matches
// identity-member-design.md §14. // internal/model/permission/SDD.md §4.1 (Redis Keys).
const ( const (
CasbinRulesRedisKey RedisKey = "permission:casbin:rules" CasbinRulesRedisKey RedisKey = "permission:casbin:rules"
UserRolesRedisKey RedisKey = "perm:user_roles" UserRolesRedisKey RedisKey = "perm:user_roles"