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.1):usecase 為 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 註冊 / 登入暫存 Session(Redis) | 郵件 / 簡訊 → Notification |
| CloudEP JWT access / refresh 生命週期 | RBAC → Permission |
| JWT 黑名單 + jti pair | |
---
## 目錄結構
```
internal/model/auth/
├── README.md # 本檔
├── config/
├── domain/
│ ├── entity/ # InviteCode、RegistrationMetadata
│ ├── enum/ # RegistrationChannel(email / 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`。
---
## Token(CloudEP JWT)
HS256;access / refresh 使用不同 secret。
| Claim | 說明 |
|-------|------|
| `tenant_id` | 租戶 ID |
| `uid` | 會員 UID |
| `typ` | `access` 或 `refresh` |
| `auth_gen` | 簽發世代(強制登出時 +1) |
| `jti` | Token 唯一 ID |
| `iat` / `exp` | 簽發 / 過期 |
| Token | 預設 TTL |
|-------|---------|
| Access | 900s(15 分鐘) |
| Refresh | 604800s(7 天) |
| Registration Session | 600s(10 分鐘) |
### 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: IssuePair(new 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
│ │
└───────┬────────┘
▼
IssueTokenPair(CloudEP JWT)
```
### Email 路徑
1. Logic 驗 tenant_slug + invite + 條款 + 密碼強度 + email 格式
2. Logic 消耗 invite(Redis 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 session(Redis 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 組合
- Social:OAuth 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` 起始即 Consume;Social 在 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+ bytes,prod 走 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.1):usecase **不可** 呼叫其他 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 MFA(AES-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 challenge(bcrypt 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: AssertResendAllowed(cooldown)
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 一旦建立 **不可改**;外部 IdP(ZITADEL / 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 + Redis(casbin 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 client(go-zero 沒有 Subscribe),詳見 `internal/library/redis/pubsub.go`。 > Redis Set + JSON 是為了 SaveAll 用 pipelined `DEL + SADD` 原子更新;Pub/Sub 走獨立 go-redis client(go-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: GetByID(tenant check)
participant Cfg as config.Defaults() UC->>Perms: GetAll(catalog 全表)
participant Repo as Mongo Repos (5) UC->>UC: ids ⊆ catalog?
participant Casbin as RBACUseCase UC->>UC: getFullParentPermissionIDs(ids)
participant Redis as PolicyAdapter UC->>RP: SetForRole(DeleteMany + 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 = nil(Check 永遠 deny)
end
Mod->>Mod: New {Permission, Role, RolePermission, UserRole, RoleMapping, AuthorizationQuery}
Mod-->>Boot: *Module(7 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: ok(fire-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 LoadPolicy(Casbin 規則載入) ### 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: GetByIDs(unique 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 roles(any-allow) loop role in roles(any-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 / Deny(403)
``` ```
### 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 enforcer(Mongo+Redis 全到位才有) sc.PermissionRBAC // RBACUseCase(Mongo+Redis 全到位才有)
sc.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用 sc.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用
``` ```
未啟用 Casbin 時 `PermissionRBAC == nil`,`Check()` 永遠 deny;middleware 會拒絕所有請求(除非 `AllowMissingActor=true`)。 未啟用 Casbin 時 `PermissionRBAC == nil`,`Check()` 永遠 deny;middleware 視 `AllowMissingActor` 決定放行或拒絕。
--- ---
## 9. HTTP API(前綴 `/api/v1/permissions`) ## HTTP API(`/api/v1/permissions`)
| Method | Path | Handler | 說明 | | Method | Path | Middleware | 說明 |
|--------|------|---------|------| |--------|------|------------|------|
| GET | `/catalog` | `getPermissionCatalog` | 全局 Catalog(tree=true 取樹狀) | | GET | `/catalog` | AuthJWT | 全局 Catalog(tree/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 / status(system 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 role(catalog 已存在)
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001,TEN-100002
# 6) 只 reseed tenant role(catalog 已存在)
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 重載 policy(HTTP) # 強制全 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 一個 enforcer,lazy 建 | 比一個 enforcer + filtered policy 簡單,且記憶體可預測 | | Pub/Sub client | 獨立 go-redis | go-zero 沒包 Subscribe |
| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時通知 + reboot 不漏 | | Catalog 改動 | seed CLI(idempotent) | catalog.json 是 SoT |
| Pub/Sub client | 獨立 go-redis,不走 go-zero pool | go-zero 沒包 Subscribe,且 Subscribe 會佔住 conn |
| Permission Catalog 改動 | seed CLI 即可(idempotent) | UI 端不直接改 catalog;seed JSON 是 SoT |
--- ---
## 15. 後續工作 ## 測試
| 項目 | 預估 | ```bash
|------|------| # 單元
| Platform admin allowlist + audit log | 後續 | go test ./internal/model/permission/...
| RoleMapping 用 SyncFromX 落地(Zitadel / LDAP / SCIM)| 隨對應 SyncFromX usecase 推進 |
| Policy reload cron worker(5 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"