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:
parent
1f3eb3c992
commit
55446b9060
23
README.md
23
README.md
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 模組
|
||||||
|
|
@ -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
|
|
||||||
│ ├── errors.go # ErrNotFound、ErrDuplicateMember 等
|
|
||||||
│ ├── redis.go # GetOTPChallengeRedisKey 等 helper
|
|
||||||
│ ├── repository/ # 7 個 repository 介面
|
│ ├── repository/ # 7 個 repository 介面
|
||||||
│ └── usecase/ # 7 個 usecase 介面 + DTO
|
│ ├── usecase/ # 7 個 usecase 介面 + DTO
|
||||||
├── repository/ # Mongo / Redis 實作
|
│ ├── const.go # BSON 欄位、UID 常數
|
||||||
├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL)
|
│ ├── errors.go # ErrNotFound / ErrDuplicateMember ...
|
||||||
├── usecase/ # 7 個 usecase 實作 + module factory + mapper
|
│ └── redis.go # GetOTPChallengeRedisKey ...
|
||||||
└── README.md # 本檔
|
├── repository/ # Mongo + Redis 實作
|
||||||
|
├── totp/ # RFC 6238 純函式
|
||||||
|
└── usecase/ # 實作 + module factory
|
||||||
```
|
```
|
||||||
|
|
||||||
`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
|
|
||||||
alt 失敗
|
|
||||||
OTP->>Redis: INCR attempts
|
|
||||||
alt attempts >= 5
|
|
||||||
OTP-->>Logic: ErrChallengeLocked
|
|
||||||
else
|
|
||||||
OTP-->>Logic: ErrInvalidOTP
|
|
||||||
end
|
|
||||||
else 成功
|
|
||||||
OTP->>Redis: DEL challenge
|
|
||||||
OTP-->>Logic: target(email)
|
OTP-->>Logic: target(email)
|
||||||
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
|
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
|
||||||
Profile-->>Logic: nil
|
|
||||||
Logic-->>Client: 204
|
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
|
|
||||||
TOTP->>Cipher: Decrypt(SecretCipher)
|
|
||||||
alt code 長度 = 6
|
|
||||||
TOTP->>TOTP: totp.Verify(±window) → step
|
|
||||||
alt OK
|
|
||||||
TOTP->>Replay: MarkUsed(timestep, TTL=90s) → fresh?
|
|
||||||
alt 已用過
|
alt 已用過
|
||||||
TOTP-->>Logic: ErrTOTPCodeReplay
|
TOTP-->>Client: 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) — 錯誤碼
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
RBAC-->>MW: Allow / Deny(403)
|
||||||
MW->>MW: result.Allow ? next : 403 (errs.AuthForbidden)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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,59 +212,57 @@ 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:
|
||||||
|
|
@ -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)。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue