From 55446b90608155e9112905fc366a419f556758ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Fri, 22 May 2026 17:18:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=B5=B1=E6=95=B4=E6=A8=A1=E7=B5=84=20?= =?UTF-8?q?README=20=E2=86=94=20SDD=20=E5=88=86=E5=B7=A5=EF=BC=8C=E7=A0=8D?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E5=85=A7=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 讓「找規格」跟「日常速查」兩種需求各有歸宿,避免同樣資訊散落多處: - 改寫 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 --- README.md | 23 +- docs/identity-member-design.md | 2838 ++---------------- etc/rbac.conf | 3 +- internal/model/auth/README.md | 291 ++ internal/model/member/README.md | 623 +--- internal/model/member/domain/const.go | 2 +- internal/model/member/domain/redis.go | 4 +- internal/model/member/domain/usecase/totp.go | 8 +- internal/model/member/totp/totp.go | 4 +- internal/model/notification/README.md | 14 +- internal/model/permission/README.md | 606 +--- internal/model/permission/domain/const.go | 2 +- internal/model/permission/domain/redis.go | 2 +- 13 files changed, 852 insertions(+), 3568 deletions(-) create mode 100644 internal/model/auth/README.md diff --git a/README.md b/README.md index f0b5bc0..feea142 100644 --- a/README.md +++ b/README.md @@ -171,13 +171,22 @@ HTTP Request 更細的說明見各子目錄 README: -- [generate/api/README.md](generate/api/README.md) — `.api` 與 `@respdoc` 約定 -- [internal/response/README.md](internal/response/README.md) — Handler / Logic 分工 -- [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 -- [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) -- [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) — Gateway 統一註冊 / 登入(`/api/v1/auth/*`,含 Email、Social、JWT) +- [`generate/api/README.md`](generate/api/README.md) — `.api`、`@respdoc`、middleware 約定 +- [`internal/response/README.md`](internal/response/README.md) — Handler / Logic 分工 +- [`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/redis/README.md`](internal/library/redis/README.md) — Redis 連線共用 +- [`docs/model.md`](docs/model.md) — `internal/model/{module}` 分層規範 +- [`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 端點欄位) ## 開發約定 diff --git a/docs/identity-member-design.md b/docs/identity-member-design.md index 2710d57..809ee29 100644 --- a/docs/identity-member-design.md +++ b/docs/identity-member-design.md @@ -1,2673 +1,303 @@ -# Identity / Member / Permission Ҳճ]pZ +# Identity / Member / Permission Ҳլ[c -> **A**GDraft] Review^ -> **AαM**GPortal API Gateway]PGW^ -> **Ѧҹ@**G[app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)]Casbin RBACBPermission TreeBRole/RolePermission^ -> **̫s**G2026-05-19 -> **e**Gs Gateway moduleAҼ{ª member-server EC +Gateway Tӷ~ȼҲա]**auth** / **member** / **permission**^P~ **ZITADEL**]^B**LDAP**]~ؿ^B**SCIM 2.0**]~ provisioning^X`C**eu͸Ҳռh]pMPp**FҲդЬݡG -yz Gateway **auth**B**member**B**permission** Tӷ~ȼҲժؼЬ[cAX **ZITADEL**]^B**LDAP**]~ؿ^B**SCIM 2.0**]~ provisioning^A䴩 **h** P **ʸUŷ|**]t毲 50 U^C +| Ҳ | | +|------|------| +| auth | [`internal/model/auth/README.md`](../internal/model/auth/README.md) | +| member | [`internal/model/member/README.md`](../internal/model/member/README.md) | +| permission | [`internal/model/permission/README.md`](../internal/model/permission/README.md) | +| notification | [`internal/model/notification/README.md`](../internal/model/notification/README.md) | -ҲդhP{XgWd [model.md](./model.md)C +hG[`docs/model.md`](./model.md)C --- -## ؿ - -1. []pؼлPh](#1-]pؼлPh) -2. [Ҳե](#2-Ҳե) -3. [~tΤu](#3-~tΤu) -4. [auth Ҳ](#4-auth-Ҳ) -5. [member Ҳ](#5-member-Ҳ) -6. [permission Ҳա]B2B ۩wq^](#6-permission-Ҳb2b-۩wq) -7. [API W](#7-api-W) -8. [Middleware ](#8-middleware-) -9. [֤߬y{](#9-֤߬y{) -10. [LDAP P SCIM](#10-ldap-P-scim) -11. [Notification Module](#11-notification-module) -12. [iŪ UID ]p](#12-iŪ-uid-]pwM) -13. [ƼҫP](#13-ƼҫP) -14. [Redis Key RW](#14-redis-key-RW) -15. [WһPʯ]100 U+ / 毲 50 U^](#15-WһPʯ100-U--毲-50-U) -16. [ؿc](#16-ؿc) -17. []w](#17-]w) -18. [I](#18-I) -19. [wMƶ](#19-wMƶ) -20. [Audit Log P Rate Limit](#20-audit-log-P-rate-limit) - ---- - -## 1. ]pؼлPh - -### 1.1 ؼ +## 1. ]pؼ | ؼ | | |------|------| -| Τ@ | ZITADEL @ IdP]t LDAP IdPBSocial Login^ | -| ~ȷ| | Gateway `member` Ҳպ޲z tenant-scoped profile | -| Ӳɫױv | Gateway `permission` Ҳա]**Casbin RBAC + Permission Tree**^F**C B2B i۩wq Role äĿ Permission** | -| Token | go-zero JWT + Redis ¦W]u¦W JWT^ | -| ~X | SCIM 2.0 + LDAP Directory Sync]AD + OpenLDAP^ | +| Τ@ | ZITADEL IdP]OIDCBLDAP IdPBSocial Login^ | +| ~ȷ| | Gateway `member` tenant-scoped profile | +| Ӳɫױv | Gateway `permission`]Casbin RBAC + Permission Tree^FB2B i۩wq Role äĿ Permission | +| ~ Token | Gateway ñ CloudEP JWTFZITADEL OIDC token ub `/auth/token/exchange` Τ@ | | W | x 100 U+ |F毲iF 50 U | -| UID | HiŪBaeAp `AMEX-10000000`Fߤ@ʥH `tenant_id + uid` | +| UID | HiŪae]p `ACME-10000003`^Fߤ@ `(tenant_id, uid)` | -### 1.2 ֤߭h +--- + +## 2. ֤߭h 1. **¾d** - `auth`GAO֡]Authentication^ - - `member`GA~ȸƬO]Profile^ + - `member`GA~ȸơ]Profile^ - `permission`GAవ]Authorization^ 2. **LDAP nJ bind** - - nJҥ ZITADEL LDAP IdP Bz - - Gateway LDAP client Ȩ Directory Sync]read-only^ + nJҤ@ߨ ZITADEL LDAP IdPFGateway LDAP client u Directory Sync]read-only^C -3. **Token Exchange** - - ~ API u Gateway ño CloudEP JWT - - ZITADEL OIDC token Ȧb `/auth/token/exchange` ϥΤ@ +3. **j** + Ҧ[ƸƥH `tenant_id` ɡFJWT `tenant_id` PШD귽@P 403C -4. **j** - - Ҧ[ƸƥH `tenant_id` - - JWT `tenant_id` PШD귽@P +4. **B2B v۩wq** + - x seed Permission Tree]t `http_path` + `http_method`^ + - ئۭq RoleAq Tree **Ŀ** Permission]۰ʸ parent^ + - Casbin H `(tenant_id, role_key, path, method)` A󯲤PW⤬ìV -5. **B2B v۩wq**]Ѧ [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)^ - - x seed Permission Tree]t `http_path` / `http_method`^ - - إߦۭq RoleAq Tree **Ŀ** Permission]`RolePermission` + ۰ʸ parent^ - - API v **Casbin** `(tenant_id, role_key, path, method)`AקKPPW⤬ۦìV - - B2C **Ū** seed ҪOA**i**۩wq Role]wM^ +5. ** vs ~Ҥh** + | ŧO | ѽ֭td | d | + |------|---------|------| + | | ZITADEL Org Policy | nJ MFABU emailBѰOKXBbw | + | ~ȯ | Gateway `member` | ~ email/phone OTPBTOTP step-up | + Gateway ****Ū `ZITADEL.email_verified` ~ȦuFŪ `member.business_email_verified`C -6. ** vs ~Ҥh**]wM^ - - **ZITADEL = **GnJ MFA]TOTP / WebAuthn / SMS^BU email ҡBѰOKXBbw - - **Gateway member = ~ȯ**G~ email / phone jw OTPBStep-up MFA - - Gateway ****̿ `ZITADEL email_verified` ~ȦuFLogic hŪ `BusinessEmailVerified` member X - - **Email / SMS OTP Gateway ۰e**] ZITADEL Notification^ - - **MFA j**Gadmin role ZITADEL Org Policy j TOTPF@ user w]jAIާ@ Gateway Step-up +6. **UseCase l** + UseCase atomic primitiveAT۩IsFly{]OTP HH X flip flag^@ߦb `internal/logic//` sơC --- -## 2. Ҳե +## 3. Ҳը̿V ``` -zwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww{ -x Portal API Gateway (go-zero) x -uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt -x generate/api/ x -x auth.api P member.api P permission.api P tenant.api P scim.apix -uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt -x internal/middleware/ x -x jwt_revoke P casbin_rbac P scim_auth P tenant_context x -uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt -x internal/model/ x -x auth/ Token ñoBBnXB¦WBauth_genBstep-upx -x member/ ProfileBIdentityBTenantBUIDBSyncBTOTPBҢx -x permission/ Casbin RBACBPermission TreeBRole]B2B ۩wq^x -x notification/ Email/SMS/Push Τ@oeBҪOBաBaudit x -uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt -x internal/library/ x -x zitadel/ P ldap/ P uid/ P casbin/ x -x notification/email P notification/sms P notification/push x -uwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwt -x internal/worker/ x -x directory_sync/ P notification_retry/ P member_anonymize/ x -|wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww} - x x x - - MongoDB Redis ZITADEL - (profile/role) (cache/blacklist) (identity/LDAP IdP) - + - Email / SMS Provider -``` +handler logic model/{module}/domain/usecase]^ + + model/{module}/repository]^ + + MongoDB / Redis -### 2.1 Ҳը̿V - -``` -handler logic model/{auth|member|permission|notification}/usecase]interface^ - - repository MongoDB / Redis - -logic import entity / repository] model.md^ - -auth member]EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM^ -auth permission]SyncRolesFromClaims^ -auth member.TOTPUseCase]step-up TOTP ҡ^ -member auth]v RevokeAllForUser^ -member notification]~ / step-up OTP He^ -permission member]iG uid sb^ -notification library/notification/{email,sms,push}]provider @^ +logic iPɩIsh model usecase]sơ^ +usecase ̿ +auth/logic member.usecase]CreateUnverified / Activate / EnsureFromOIDC^ +auth/logic permission.usecase]SyncRolesFromClaimsAӡ^ +auth/logic notification.Notifier]OTP / UH^ +member/logic notification.Notifier]~ҡBstep-up OTP^ +permission WߡAϦV̿ ``` --- -## 3. ~tΤu +## 4. ~tΤu -| O | ZITADEL | Gateway auth | Gateway member | Gateway permission | Gateway notification | -|------|---------|--------------|----------------|-------------------|----------------------| -| U / nJ]OIDC / LDAP / SCIM^ | ? | | EnsureFromOIDC/LDAP/SCIM | SyncRoles | X | -| x͵U]ӡAt email OTP^ | ]local user^| X | LifecycleUseCase + OTPUseCase | X | H OTP | -| KX / MFA / ѰOKX | ? | X | X | X | X | -| MFA j | ? Org Policy | X | X | X | X | -| Google / LINE / Apple | ? IdP | X | X | X | X | -| LDAP nJ | ? LDAP IdP | X | X | GroupRole Mg | X | -| Access / Refresh Token]~^ | X | ? CloudEP JWT | X | X | X | -| Step-up Token]Iާ@^ | X | ? ñ step_up_token | OTP / TOTP | Logic u | OTP He | -| ~ TOTP]Authenticator^ | X | X | ? secret [Kxs + | X | X | -| JWT ¦W | X | ? Redis | X | X | X | -| ~ UID | X | X | ? | X | X | -| Profile | X | X | ? | X | X | -| ~ Email / Phone | X | X | ? Verification y{ | X | ? OTP He | -| Email / SMS / Push oe | X | X | X | X | ? Τ@Jf + ҪO + | -| |C / A | X | X | ? | ݱv | ܧq]B^ | -| API Ӳɫv | ʲɫ Role | X | X | **Casbin RBAC**]path + method^ | X | -| SCIM Users/Groups | iPB | X | ? ~ȼgJ | ? GroupRole | X | -| LDAP Directory Sync | X | X | ? Worker | ? GroupRole | PB`iĵ | +| O | ZITADEL | auth | member | permission | notification | +|------|---------|------|--------|------------|---------------| +| OIDC / Social Login | ? | callback JWT | EnsureFromOIDC | SyncRoles | X | +| x Email + Password U | user]local^ | logic s | LifecycleUseCase | X | H OTP | +| MFA j | ? Org Policy | X | X | X | X | +| LDAP nJ | ? LDAP IdP | X | X | GroupRole | X | +| ~ JWT | X | ? CloudEP | X | X | X | +| ~ TOTP (Authenticator) | X | X | ? AES-GCM s secret | X | X | +| ~ Email/Phone OTP | X | X | ? OTPUseCase | X | ? ҪO + oe | +| API Ӳɫv | Role | X | X | ? Casbin | X | +| SCIM Users/Groups | iPB | X | ~ȼgJ | GroupRole | X | +| Directory Sync | X | X | Worker | GroupRole | PB`iĵ | -### 3.1 h +### 4.1 ? ZITADEL Org ``` -1 CloudEP Tenant = 1 ZITADEL Organization = 1 ƹj +1 CloudEP Tenant = 1 ZITADEL Organization = 1 ƹj ``` | | ӷ | γ~ | |------|------|------| -| `tenant_id` | ZITADEL `org_id` | Bv | -| `identity_id` | ZITADEL `sub` | Mg | -| `uid` | Member Ҳղ | ~ȷ| ID]p `AMEX-10000000`^ | +| `tenant_id` | Gateway | / v | +| `tenant.org_id` | ZITADEL `org_id` | tι | +| `member.zitadel_user_id` | ZITADEL `sub` | Mg | +| `member.uid` | `UIDGenerator` | ~ȥiŪD | -#### Tenant إ߶ǡ]wMGGateway دZ^ +### 4.2 Tenant إߡ]Saga^ ``` -1. POST /api/v1/admin/tenants { slug, uid_prefix, type, ... } - Mongo upsert tenants {status: "provisioning", org_id: ""} -2. ZITADEL Mgmt.CreateOrganization(name=slug) - org_id -3. UPDATE tenants {org_id, status: "active"} -4. seed w] Role + Casbin policy reload -5. ^ tenant payload -ѸvG -- BJ 2 status = "failed"Acron ա]ưhסA3 HuJ^ -- BJ 3 status = "orphan_zitadel_org"Acron øɸj +1. POST /api/v1/admin/tenants + Mongo upsert tenants { status: provisioning, org_id: "" } +2. ZITADEL Mgmt.CreateOrganization(slug) + org_id +3. UPDATE tenants { org_id, status: active } +4. seed system roles + Casbin reload +Ѹv]cron C 5 ^G +- BJ 2 status=failedAưhסA3 iĵ +- BJ 3 status=orphan_zitadel_orgAyɸj ``` -> Saga GGateway DBZITADEL qFv cron C 5 `status in ("failed", "orphan_zitadel_org")` թΧiĵC - -### 3.2 +### 4.3 | | nJ | LDAP | v | |------|------|------|------| -| **B2C** | Email / Social | L | tιw] Role]iΤ`۩wq^ | -| **B2B** | ZITADEL LDAP IdP | | **۩wq Role + Permission** | -| **Hybrid** | Social + LDAP | | B2B ۩wqF~Ȥ B2C ŪҪO | - -### 3.3 ZITADEL p]wMGSelf-hosted^ - -- **p覡**GSelf-hosted]۫ء^AP Gateway / Mongo / Redis PҩΦP VPC -- **LDAP **GZITADEL һݯઽs~ AD / OpenLDAP]`GVPNBMuB DMZ o^ -- **Management API / JWKS**GGateway zL URL sAg -- **]w**G`etc/gateway.yaml` `Zitadel.Issuer` / `MgmtURL` V self-hosted I - -### 3.4 U|]wMGGateway Τ@U BFF^ - -> **W**G[auth-unified-registration.md](./auth-unified-registration.md)]2026-05-21 _ǡF`Kn^ - -Gateway **S** `/api/v1/auth/register*` @ B2C Τ@UJfFZITADEL @ identity ݡ]bKBOIDC^A**A**nDϥΪ̸ ZITADEL Hosted Register UIC - -| | U| | | -|---------|----------|------| -| **B2C Email** | `POST /auth/register` OTP `POST /auth/register/confirm` | Logic sơGinvite consume `zitadel.CreateHumanUser` `Lifecycle.CreateUnverified` registration OTP `Activate` CloudEP JWT | -| **B2C Social]Google^** | `POST /auth/register/social/start` OAuth `GET /auth/register/social/callback` | OAuth **e**jw invite session]Redis^Fcallback invite `EnsureFromOIDC` registration metadata JWT | -| **B2B]LDAP^** | IT b AD / OpenLDAP رbFDirectory Sync w provision | nJ LDAP IdP `EnsureFromLDAP` JITF**g** register API | -| **B2B]SCIM^** | HR / Okta / Entra SCIM Create User | SCIM endpoint g ZITADEL + GatewayF**g** register API | - -**ӰȳWh]Logic hAD usecase^G** - -- Invite code ****]`Member.Registration.RequireInviteCode`Aw] `true`^ -- ڪ `accept_terms_version` -- Ue **o** CloudEP JWTFconfirm / social callback ~ `IssuePair` -- Invite ӫY ZITADEL / member **^u invite**]F auth-unified-registration 9^ -- Social nJ]DU^ **`/auth/login/social/*`**AP register session ** state e**]`login:` vs `reg:`^ - -**nJ]DU^** [auth-unified-registration.md 3.3](./auth-unified-registration.md#33-nJDU)G`/auth/login`B`/auth/token/refresh`B`/auth/token/exchange`BSocial loginC - -> ZITADEL email ҥΩ **** nJeFx͵UtH Gateway registration OTP]`OTPPurposeRegistrationEmail`^T{~ `Activate`C - -### 3.5 x MFA j]wM^ - -- ZITADEL Org Policy ]wG** admin role**]`tenant_owner` / `tenant_admin` / `platform_super_admin`^nJɱj TOTP / WebAuthn -- @ user w]j]קK B2C y^ -- I~Ⱦާ@ Gateway Step-up MFA]5.6^AP ZITADEL MFA **N** +| **B2C** | Email / Social | X | tιw] Role]ŪҪO^ | +| **B2B** | ZITADEL LDAP IdP | ? | ۩wq Role + Permission | +| **Hybrid** | Social + LDAP | ? | B2B ۩wqF~Ȥ B2C ҪO | --- -## 4. auth Ҳ +## 5. UID ]p -|G`internal/model/auth/` +| Wh | | +|------|----| +| 榡 | `{TenantUIDPrefix}-{Sequence}` | +| Prefix | 2~4 Ӥjgr]Tenant إ߮ɳ]Aߤ@^ | +| _lǸ | **10,000,000**]7 _AקK `ACME-1` oصu UID^ | +| ߤ@ | `(tenant_id, uid)` | +| d | `ACME-10000003` | -### 4.1 ¾d +@G`member.UIDGenerator` Redis `INCR member:seq:{tenant}`F INCR=1 ɦ۰ `INCRBY 9_999_999` ɨ_ȡC -- ZITADEL OIDC token]id_token / authorization_code + PKCE^ -- s `member.EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` P `permission.SyncRolesFromClaims` -- ño CloudEP JWT]access + refresh^ -- **ño Step-up Token**]Iާ@ΡAuةR 5minF 5.6^ -- nXGjti ¦W -- qġG`auth_gen`]v / KX / vjs^ +--- -### 4.2 UseCase +## 6. Permission ҫ -```go -type TokenUseCase interface { - Exchange(ctx context.Context, req *ExchangeRequest) (*TokenPair, error) - Refresh(ctx context.Context, req *RefreshRequest) (*TokenPair, error) - Logout(ctx context.Context, req *LogoutRequest) error - RevokeAllForUser(ctx context.Context, tenantID, uid string) error -} - -type StepUpTokenUseCase interface { - Issue(ctx context.Context, tenantID, uid, action string) (stepUpToken string, err error) - Verify(ctx context.Context, token, expectedAction, tenantID, uid string) (jti string, err error) - MarkUsed(ctx context.Context, jti string) error // 榸 -} +``` +xh (Platform-wide) h (per-tenant) +zwwwwwwwwwwwwwwww{ zwwwwwwwww{ +x Permission x ww Ŀ ww x Role x +x Catalog (Tree) x x x +|wwwwwwwwwwwwwwww} x x + x UserRolex + x x + x M x + xRoleMap x + |wwwwwwwww} + LoadPolicy + Casbin Enforcer (Redis adapter) + + CasbinRBAC Middleware ``` -### 4.3 CloudEP JWT Claims +| | | +|------|------| +| Permission | xŸ`I] dot notation^C**Name i**Fo `status=close` | +| Role | ᤺A`(tenant_id, key)` uniqueF**Key i**]~ IdP mapping j Key^ | +| RolePermission | qNsF۰ʸ parent permission | +| UserRole | `source` Ϥ `manual` / `zitadel` / `ldap` / `scim`F`ReplaceForSource` ~ manual | +| RoleMapping | ~ group/role `role.key`A SyncFromX ½Ķ IdP claims | +| Casbin Rule | `[tenant, role.key, http_path, http_methods, perm.name]` | -```go -type Claims struct { - jwt.RegisteredClaims // t jti, exp, iat - TenantID string `json:"tenant_id"` - UID string `json:"uid"` - Typ string `json:"typ"` // access | refresh | step_up - AuthGen int64 `json:"auth_gen"` // qĥN]ño = redis.GET eȡFsb 0^ - Action string `json:"action,omitempty"` // typ=step_up ɥAw\檺I action -} +**h pod PB**GRedis Pub/Sub Y + 5 cron ©]©ijb svc hƵ{AҲդء^C + +--- + +## 7. Middleware + +``` +HTTP Request + +[AuthJWT] ѪR Bearer actor.WithActor(ctx, tenant, uid) + +[CasbinRBAC] b AuthJWT Fany-allow LҦ open role + +handler logic usecase ``` -> **JWT role / permission ַ**CMiddleware Cq `perm:user_roles:{tenant_id}:{uid}` cache Ūe role keys A enforceFקKuW / M / ܧvv token ٯΡC -> ܧߧYͮľa `auth_gen` + cache invalidateF̿ token eC +覡Gb `.api` `@server (middleware: AuthJWT,CasbinRBAC)` ŧiA`make gen-api` | middleware gi `routes.go`C**T** b `gateway.go` `server.Use(...)` 챾CԨ [`generate/api/README.md`](../generate/api/README.md#middleware-go-zero-Wq)C -### 4.4 JWT ]w]go-zero^+ Secret Rotation]wM^ +Actor @ߥ `internal/library/actor.WithActor` / `ActorFromContext`AnbU logic package ۩w `actorKey struct{}`C + +--- + +## 8. Notification P~ + +> ԲӡG[`internal/model/notification/README.md`](../internal/model/notification/README.md)B[`internal/model/member/README.md`](../internal/model/member/README.md) + +| y{ | sƼh | Ψ쪺 atomic | +|------|--------|---------------| +| xU email OTP | `logic/auth.RegisterLogic` | `member.OTP.Generate` + `notifier.Send(verify_registration_email)` | +| ~ email/phone | `logic/member.startVerification` + `confirmVerification` | `VerifyRate` `OTP.Generate` `Notifier.Send` `Profile.SetXxxVerified` | +| TOTP jw / step-up | `logic/member.*_t_o_t_p_*` | `TOTP.StartEnroll` / `ConfirmEnroll` / `VerifyCode` | + +> Notification O library-style domainA ServiceContext Ұ `RetryWorker`]B + DLQ^CProvider chainGSMTP / SES / Mitake / MockA `Sort` failoverC + +--- + +## 9. Rate Limit / Audit]W^ + +| DD | {p | W | +|------|------|------| +| OTP / ~ cooldown | ? w@]`member.VerifyRate`ARedis SETNX + daily INCR^ | X | +| HTTP rate limit | @ | ij go-zero middleware + Redis sliding-window]ZSET^Akey = `rl:{dim}:{path}` | +| Audit Log | @ | `audit_logs` collection]W DB ij^F`critical` PBgA`info` 妸FTTL 90d | + +--- + +## 10. WҦҶq]100 U+ |^ + +| | ij | +|------|------| +| `members` | hashed `tenant_id`]毲 50 UiƦX unique index ^ | +| Casbin policy | @ tenant @ enforcerAlazy إߡFRedis Set xs rule | +| JWT ¦W | u¦WwMP jtiATTL = ܦ۵ML | +| Member list d | ja `tenant_id` + `(create_at, _id)` cursor | +| Notification DLQ | WL `MaxRetry` gJ `notification_dlq`Fadmin CLI | + +--- + +## 11. wMƶ + +| DD | M | +|------|------| +| Tenant إ߶ | Gateway دZ]status=provisioning^ ZITADEL CreateOrg `org_id`Fv cron | +| Permission name W | T]Q RolePermission / Casbin policy.name ޥΡ^Fo `status=close` | +| Role.Key W | T]~ IdP mapping j^ | +| `is_system` Role | iRBi status | +| `manual` source ReplaceForSource | ڵ] SyncFromX ~~ʫ^ | +| Gateway Τ@U | N ZITADEL Hosted PageFemail + social P UXAinvite logic | +| Email / SMS OTP | Gateway `notification` ۰eA**** ZITADEL Notification | +| ~ TOTP | P ZITADEL TOTP WߡFsecret AES-GCM s Mongo | +| Email Ҧu | Ū `member.business_email_verified`A****Ū ZITADEL `email_verified` | +| UseCase I | jGly{b logic sơ]model.md 6.1^ | +| Casbin h pod PB | Redis Pub/Sub Y + 5min cron © | +| Permission Catalog ܧ | seed CLI]`cmd/permission-seed`^FUI i | + +--- + +## 12. ؿc + +``` +internal/ +uww library/ +x uww actor/ # ctx actor helper] module @ key^ +x uww crypto/ # AES-GCM cipher]TOTP secret KEK^ +x uww errors/ # 8 X SSCCCDDD +x uww mongo/ # DocumentDB + cache +x uww redis/ # go-zero client + Pub/Sub +x uww validate/ # struct +x |ww zitadel/ # ZITADEL HTTP client +uww middleware/ +x uww authjwt_middleware.go # JWT actor +x |ww casbinrbac_middleware.go # any-allow RBAC +uww model/ +x uww auth/ # ܽнX / U metadata / OAuth session / JWT +x uww member/ # Tenant / Member / Identity / OTP / TOTP / UID +x uww notification/ # Email / SMS / ҪO / Worker / DLQ +x |ww permission/ # Permission Catalog / Role / RolePermission / Casbin +|ww worker/ + |ww notification_retry/ # Notification RetryWorker runner +``` + +--- + +## 13. ]w + +Dn϶]㨣 [`etc/README.md`](../etc/README.md) + `etc/gateway.dev.example.yaml`^G ```yaml -Auth: - AccessExpire: 900 # 15 - ActiveKID: v2 # eño kid - Keys: # ҥi kid W]tbhЪ^ - - kid: v1 - Secret: ${JWT_ACCESS_SECRET_V1} - - kid: v2 - Secret: ${JWT_ACCESS_SECRET_V2} - -RefreshAuth: - AccessExpire: 604800 # 7 - ActiveKID: v2 - Keys: - - kid: v1 - Secret: ${JWT_REFRESH_SECRET_V1} - - kid: v2 - Secret: ${JWT_REFRESH_SECRET_V2} - -StepUp: - TokenTTLSeconds: 300 - ActiveKID: v1 - Keys: - - kid: v1 - Secret: ${JWT_STEPUP_SECRET_V1} -``` - -**Rotation y{G** - -``` -1. sW v(N+1) key Keys] ActiveKID^ rolling deploy -2. ActiveKID = v(N+1) s token ηs kid ñF kid token i -3. token L]access 15min / refresh 7d^ -4. q Keys kid rolling deploy -``` - -- JWT header a `kid`AҮɨ `kid` secretF䤣 `401 invalid_kid` -- go-zero JWT middleware ȦY secretA**ۼg `JwtMultiKeyMiddleware`** NΫem]b `JwtRevokeMiddleware` e^ -- ZITADEL Token ExchangeBStep-up @Φ[c - -`.api` O@ѡG - -```api -@server(jwt: Auth, middleware: JwtMultiKeyMiddleware,JwtRevokeMiddleware) -``` - -### 4.5 ¦W浦]u¦W JWT^ - -#### Issue Token Pair ɰO] logout a refresh^ - -``` -SET auth:jwt:pair:{access_jti} = refresh_jti TTL = access TTL -SET auth:jwt:pair:{refresh_jti} = access_jti TTL = refresh TTL -``` - -#### Token MP]nX^ - -``` -Key: auth:jwt:bl:{jti} -Value: 1 -TTL: token ѾlĮɶ]exp - now^ -``` - -``` -POST /auth/logout (Bearer access_jwt) - 1. access_jti SET auth:jwt:bl:{access_jti} - 2. GET auth:jwt:pair:{access_jti} refresh_jti]Ysb^ - 3. SET auth:jwt:bl:{refresh_jti} - 4. DEL auth:jwt:pair:{access_jti} / auth:jwt:pair:{refresh_jti} -``` - -#### Refresh Token ]wM^+ Reuse Detection - -``` -POST /auth/token/refresh - 1. refresh_jwt]typ=refreshBLBauth_gen ġ^ - 2. Y refresh_jti wb¦WG - Qѩέ INCR auth:gen:{tenant_id}:{uid}]MP chain^ - ^ 401Aüg audit log - 3. ños access_jwt + s refresh_jwt]s jti^ - 4. refresh_jtiFY access jti LA@ֶ¦W - 5. gJs auth:jwt:pair -``` - -- C refresh ]Refresh Token Rotation^ -- **Reuse detection**G refresh QĤGϥ PsΡAߧYqMP user - -#### Token Exchange - -``` -POST /auth/token/exchange { tenant_slug, id_token } - 1. zitadel.VerifyIDToken] audBissBexpBsignature^ - 2. jˬd id_token.iat b̪ 5 - 3. SETNX auth:exchange:nonce:{id_token.jti}=1 TTL 10minF 409 wϥ - 4. tenant_slug tenant.org_id == id_token.org_id - 5. EnsureFromOIDC / SyncRoles / IssueTokenPair -``` - -#### Step-up Token]榸ʡB action^ - -``` -Key: auth:stepup:used:{jti} SETNX TTL = step_up_token TTL -Value: 1 -``` - -- TTLG5 -- ClaimsG`typ=step_up` + `action`]p `change_business_email`^ -- Logic huG - 1. step_up JWT `typ == "step_up"`B`tenant_id`B`uid`B`action == expected` - 2. `SETNX auth:stepup:used:{jti}=1`Awsb Aڵ - 3. qL氪Iާ@Ftoken Y@o -- Step-up token ****i jti ¦WtΡF榸ʾa `auth:stepup:used` Yi - -#### qġ]v / KX / SCIM deactivate / **vܧ**^ - -``` -Key: auth:gen:{tenant_id}:{uid} -Value: ơAw] 1Fƥoͮ INCR -``` - -Middleware ˬdG`token.auth_gen >= redis.auth_gen`A_h 401C - -> **wM**GUserRole /MPB~ Group MgɭP user role ܧ **`INCR auth_gen`**]ıjsAϥΪ̻ݭs exchange/refresh os auth_gen^C -> -> RolePermission ܧ󤣧ܡuϥΪ̦ǨvAu `LoadPolicy(tenant_id)` + v֨ġFYӧ令H JWT /vַӡA~ݭnPB `INCR auth_gen`C - -> JWT permission]קK token Lj^Fqĥ `auth_gen`A榸nX jti ¦WC - -### 4.6 Middleware ˬd - -``` -0. Platform Admin allowlist R]platform tenant + platform_super_admin role break-glass UID^ - audit.LogPlatformBypass -1. go-zero JWT ñ + exp -2. typ == "access"]O@ API^ -3. NOT EXISTS auth:jwt:bl:{jti} -4. claims.auth_gen >= redis auth:gen:{tenant}:{uid} - - redis key sb 0 - - ño token claims.auth_gen = redis.GET 0 -5. `J contextGtenant_id, uid]role keys ѤU@h CasbinRBACMiddleware q cache J^ +Mongo: {...} +Redis: {...} +Auth: # JWT secret / TTL / RegistrationSessionTTLSeconds +Member: # OTP / TOTP / Registration +Permission: # Casbin.Enabled / ModelPath / PolicyAdapter / Cache / Reload +Notification:# Email / SMS / Async / RatePerTenant +Zitadel: # Issuer / ServiceUserToken / Google* / DefaultOrgID ``` --- -## 5. member Ҳ +## 14. i׳t -|G`internal/model/member/` - -### 5.1 ¾d - -- | Profile CRUD]tenant-scoped^ -- Identity Mg]`zitadel_sub` ? `uid`^ -- Tenant metadata P LDAP PB]w -- UID ͡]iŪ榡^ -- SCIM ~ȼgJ]SCIM `id` / Gateway UID + Ȥ `externalId`^ -- Directory Sync Worker]AD + OpenLDAP^ -- |A]active / suspended / deleted^ q auth MP token -- **~ȯ**Gbusiness email / phone jw + OTP ۰e -- **Step-up MFA OTP **]ft auth Ҳñ step_up_token^ - -### 5.2 UseCase - -> **]ph]I model.md^**GC UseCase O**l~Ⱦާ@**A**]eBJsb**Cy{sơ]puU HҫH ҥΡv^ **logic h**Φh UseCase ˡFhutd@ʧ@ + Ƨ@ΡC -> -> hG -> 1. **Atomic primitives**Gº骺@ʧ@] memberB OTPB OTPBH notification^CLogic iNզXAy{@ΡC -> 2. ~~**Composite**~~G쥻]QuX atomic wզnֱզXv**wo**C -> - P [model.md 6.1](./model.md) IJGusecase TIsC -> - ثe@u atomic]`OTPUseCase`B`TOTPUseCase`B`ProfileUseCase` ^AhBJy{]p verify-email = `OTP.Generate` `Notifier.Send` `Profile.SetBusinessEmailVerified`^@ߦb **logic h**sơAlogic handler ۤvh usecase interfaceC -> - U 5.2.2 `Od `VerificationUseCase` wqȬu**޿yyzѦ**vA|b `domain/usecase/` X{C -> -> ~޿]APIBhandlerBy{sơ^ثe**@**FTƤC - -#### 5.2.1 Atomic primitives - -```go -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// ProfileGŪg member ]tҥ / vAܾE^ -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type ProfileUseCase interface { - GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) - Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error) - List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) - - // ~ email / phone XФ]Q Verification Υ~y{ϥΡ^ - SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error - SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error -} - -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// LifecycleGAܾE@ʧ@FHHBñ token -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type LifecycleUseCase interface { - // x͵UGإ unverified member]H OTPAo token^ - CreateUnverified(ctx context.Context, req *CreatePlatformMemberRequest) (*MemberDTO, error) - // ҥΡGunverified activeFcaller TOҦemҤwqL - Activate(ctx context.Context, tenantID, uid string) error - // vGactive suspendedFM token]M token auth Ҳհ^ - Suspend(ctx context.Context, tenantID, uid, reason string) error - // _vGsuspended active - Reactivate(ctx context.Context, tenantID, uid string) error - // nRGactive|suspended deleted]|ߨΦWơF30 ѫ worker Bz 5.7^ - SoftDelete(ctx context.Context, tenantID, uid string) error - // ҥεU]OɲMzFu unverified Ρ^ - AbortPending(ctx context.Context, tenantID, uid string) error -} - -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// ProvisioningG~ӷ Gateway member JIT / sync upsert -// CӨӷWߤ@Ӱʧ@Femail ӷ IdP wҡAA OTP -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type ProvisioningUseCase interface { - // ZITADEL OIDC token exchangeG id_token claims W upsert]B2C / Social IdP^ - EnsureFromOIDC(ctx context.Context, req *EnsureFromOIDCRequest) (*MemberDTO, error) - // ZITADEL LDAP IdP nJ JITF Directory Sync worker e - EnsureFromLDAP(ctx context.Context, req *EnsureFromLDAPRequest) (*MemberDTO, error) - // SCIM Create / Update User - EnsureFromSCIM(ctx context.Context, req *EnsureFromSCIMRequest) (*MemberDTO, error) -} - -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// OTPGatomicBpurpose-agnostic @ʱKX -// HHBs memberFcaller code ۦzL NotifierUseCase 뻼 -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type OTPUseCase interface { - // ͦGbcrypt s redisA^ challenge_id + X code]@ʦ^ǡ^ - Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, error) - // ҡG\h invalidateFpurpose P challenge إ߮ɤ@P - Verify(ctx context.Context, req *VerifyOTPRequest) error - // Dʥġ] challenge / U^ - Invalidate(ctx context.Context, tenantID, challengeID string) error -} - -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// TOTP]Authenticator App^G 5.8 -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type TOTPUseCase interface { - StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error) - ConfirmEnroll(ctx context.Context, tenantID, uid, code string) (backupCodes []string, err error) - VerifyCode(ctx context.Context, tenantID, uid, code string) error - Disable(ctx context.Context, tenantID, uid string) error - RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) -} - -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// Tenant -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type TenantUseCase interface { - Create(ctx context.Context, req *CreateTenantRequest) (*TenantDTO, error) - ResolveBySlug(ctx context.Context, slug string) (*TenantDTO, error) - ConfigureLDAP(ctx context.Context, req *ConfigureLDAPRequest) error -} - -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -// SCIM Resource handlers -// wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww -type ScimUseCase interface { - CreateUser(ctx context.Context, req *ScimCreateUserRequest) (*ScimUserDTO, error) - GetUser(ctx context.Context, req *ScimGetUserRequest) (*ScimUserDTO, error) - PatchUser(ctx context.Context, req *ScimPatchUserRequest) (*ScimUserDTO, error) - DeleteUser(ctx context.Context, req *ScimDeleteUserRequest) error - PatchGroup(ctx context.Context, req *ScimPatchGroupRequest) error -} - -type DirectorySyncUseCase interface { - SyncTenant(ctx context.Context, tenantID string) (*SyncResult, error) -} -``` - -#### 5.2.2 Composite]iF`βզXKQ]^ - -> Composite uIs Atomic primitives + library / notifierA**󤣥i atomic XƧ@**C -> Logic iܥ composite]²污p^Ϊ atomic]SݨD^C - -```go -// ~ email / phone = OTP.Generate + Notifier.Send + Profile.SetXxxVerified -type VerificationUseCase interface { - StartEmailVerify(ctx context.Context, tenantID, uid, target string) (*OTPChallengeDTO, error) - ConfirmEmailVerify(ctx context.Context, tenantID, uid, challengeID, code string) error - StartPhoneVerify(ctx context.Context, tenantID, uid, target string) (*OTPChallengeDTO, error) - ConfirmPhoneVerify(ctx context.Context, tenantID, uid, challengeID, code string) error -} - -// Step-up = (TOTP.VerifyCode OTP.Generate+Notifier.Send/OTP.Verify) + auth.StepUpToken.Issue -type StepUpUseCase interface { - Start(ctx context.Context, tenantID, uid string, req *StepUpStartRequest) (*StepUpChallengeDTO, error) - Confirm(ctx context.Context, tenantID, uid string, req *StepUpConfirmRequest) (stepUpToken string, err error) -} -``` - -#### 5.2.3 Request / DTO - -```go -// Provisioning -type EnsureFromOIDCRequest struct { - TenantID string - ZitadelSub string - Email string - EmailVerified bool // Ӧ id_token claimFOIDC q` true - DisplayName string - Locale string - RawClaims map[string]any -} - -type EnsureFromLDAPRequest struct { - TenantID string - ExternalID string // objectGUID / entryUUID - LDAPDN string - Username string - Email string - DisplayName string - Groups []string - Source enum.RoleSource // ldap_sync | ldap_jit -} - -type EnsureFromSCIMRequest struct { - TenantID string - ExternalID string // SCIM externalId] UID^ - UserName string - Email string - DisplayName string - Active bool - RawPayload map[string]any -} - -// Platform registration -type CreatePlatformMemberRequest struct { - TenantID string - Email string - PasswordHash string // Yϥ ZITADEL local userAdš] ZITADEL ޡ^ - DisplayName string - Language string - // |ߧY activeFs member.status = unverified -} - -// OTP -type GenerateOTPRequest struct { - TenantID string - Purpose enum.OTPPurpose // registration_email | business_email | business_phone | step_up | password_reset | ... - Identifier string // q`O uidFU uid |sbɥi hash(email) - Length int // 0 = config w]]6^ - TTLSeconds int // 0 = config w]]300^ -} - -type OTPChallengeDTO struct { - ChallengeID string - Code string // Generate ɦ^Ǥ@]X^Fcaller ۭt뻼 - ExpiresIn int -} - -type VerifyOTPRequest struct { - TenantID string - ChallengeID string - Code string - Purpose enum.OTPPurpose // A challenge QɥΨLγ~ -} - -// Step-up -type StepUpStartRequest struct { - TenantID string - UID string - Action enum.StepUpAction - PreferChannel enum.Channel // iGtotp | sms | emailFwh 5.6 u -} - -type StepUpChallengeDTO struct { - ChallengeID string // TOTP L challenge_id ]i^TwȡFConfirm ɤ|h - Channel enum.Channel - ExpiresIn int -} - -type StepUpConfirmRequest struct { - TenantID string - UID string - ChallengeID string - Code string - Action enum.StepUpAction -} -``` - -#### 5.2.4 Enum - -```go -// member/enum/otp_purpose.go -type OTPPurpose string -const ( - OTPPurposeRegistrationEmail OTPPurpose = "registration_email" - OTPPurposeBusinessEmail OTPPurpose = "business_email" - OTPPurposeBusinessPhone OTPPurpose = "business_phone" - OTPPurposeStepUp OTPPurpose = "step_up" - OTPPurposePasswordReset OTPPurpose = "password_reset" // wd -) - -// auth/enum/step_up_action.go]wsb 5.6Aŧi󦹡^ -type StepUpAction string -const ( - StepUpChangeBusinessEmail StepUpAction = "change_business_email" - StepUpChangeBusinessPhone StepUpAction = "change_business_phone" - StepUpDeleteMember StepUpAction = "delete_member" - StepUpTenantAdminForceStatus StepUpAction = "tenant_admin_force_status" - StepUpRevokeAllSessions StepUpAction = "revoke_all_sessions" - StepUpDisableTOTP StepUpAction = "disable_totp" -) -``` - -### 5.3 |ͩRgA - -| A | yN | Ƨ@ | -|------|------|--------| -| `unverified` | **ȥx͵U**|X{Gmember wإߡAU email |qL OTP | ñ tokenBinJFO cron `AbortPending` Mz | -| `active` | `ϥ | X | -| `suspended` | v]޲zާ@ / ^ | `auth.RevokeAllForUser`]`INCR auth_gen`^ | -| `deleted` | nR | M cacheBMP tokenBZITADEL disableF30 ѫΦWơ]5.7^ | - -> Ӧ OIDC / LDAP / SCIM member **ج `active`**]email Ѩӷ IdP wҡ^Fu platform-native U|gL `unverified`C -> ~ email / phone ҥHWߺXС]`BusinessEmailVerified` / `BusinessPhoneVerified`^ܡAPͩRgAѽC - -#### Member Source of Truth]wM^ - -| O | d | SoT | 欰 | -|---------|------|-----|------| -| ѧO | `zitadel_sub`B`ZitadelEmail`B`DisplayName`]IdP^BZITADEL `status` | **ZITADEL** | C token exchange / webhook PBFGateway ig | -| ~ȸ | `BusinessEmail/Phone(+Verified)`B`Language`B`Currency`B`Avatar`B`Preferences` | **Gateway** | ~ API gF^ ZITADEL | -| Provisioning ӷ | `external_id`B`ldap_dn`BSCIM sզ | **ӷt**]LDAP/SCIM^ | sync replaceFGateway s | - -> סG`Member.Origin` ХDӷFuProvisioningvOigdCGateway UI ~ûiF鶴/Provisioning ݨӷtΡC - -### 5.4 ~ȯҼҫ]wM^ - -```go -// Member J + `sW -type Member struct { - TenantID string - UID string - ZitadelUserID string // ZITADEL sub]OIDC / LDAP IdP / platform local user |^ - ZitadelEmail string // ӷ IdP ѪnJ email - DisplayName string - Avatar string - Phone string - Language string - Currency string - - Status enum.MemberStatus // unverified | active | suspended | deleted - Origin enum.MemberOrigin // platform_native | oidc | ldap | scim - PasswordHash string // xͥB ZITADEL local user ɤ~Fld - - BusinessEmail string // ~ email]iP ZitadelEmail P^ - BusinessEmailVerified bool - BusinessEmailVerifiedAt int64 - BusinessPhone string - BusinessPhoneVerified bool - BusinessPhoneVerifiedAt int64 - - TOTPEnrolled bool - TOTPSecretCipher string - TOTPEnrolledAt int64 - TOTPBackupCodesHash []string - - CreateAt int64 - UpdateAt int64 - DeletedAt int64 // soft delete ɶ - AnonymizedAt int64 // ΦWƮɶ -} -``` - -> **Origin** ȡG -> - `platform_native`GGateway x͵U]ft ZITADEL local user Gateway ۺޱKX^ -> - `oidc`GSocial / ZITADEL Hosted UI IdP Ӫ -> - `ldap`GzL ZITADEL LDAP IdP Directory Sync -> - `scim`GHR / Entra / Okta e - -> `Member.Origin` Mw Profile UI igdG -> - `zitadel_local`G]IdP email/name^ŪAݨ ZITADEL UI F~ig -> - `ldap`G + provisioning ҰŪ] Directory Sync @^F~ig -> - `scim`G + provisioning SCIM Provider eAŪF~ig -> -> `UserRole.Source` `manual / zitadel / ldap / scim`AvT sync replace d] 6.10^C̦Uq¾C - -| | ӷ | γ~ | +| Ҳ | A | Ƶ | |------|------|------| -| `ZitadelEmail`]J^ | OIDC claim | nJbѧOA~Ȧu | -| `BusinessEmail` | ~ API jw + OTP | ~ȳqB~Ȧu | -| `BusinessPhone` | ~ API jw + OTP | SMS qBStep-up MFA qD | - -**Verification Challenge]J MongoAȦs RedisATTL 5min^G** - -```go -type VerificationChallenge struct { - TenantID string - UID string - Kind enum.VerifyKind // email | phone | step_up - Target string // email/phone تaFstep_up action - CodeHash string // bcrypt(otp) - AttemptCnt int // ѦơAWL MaxAttempts - ExpireAt int64 // epoch ms - CreateAt int64 -} -``` - -### 5.5 OTP 뻼]wMGzL Notification Module^ - -~ / step-up OTP **@ߨ** `notification.NotifierUseCase`A****b member Ҳժ provider SDKCNotification module Τ@Bz provider BҪOBidempotencyBաBaudit] 11^C - -```go -// member.VerificationUseCase Is -nu.Notifier.Send(ctx, ¬ification.SendRequest{ - TenantID: tenantID, - UID: uid, - Channel: enum.ChannelEmail, - Kind: enum.NotifyVerifyEmail, - Target: targetEmail, - Locale: member.Language, - Data: map[string]any{"code": otp, "expires_in": 300}, - IdempotencyKey: challengeID, // P challenge |o - DoNotPersistBody: true, // OTP J notification.body - Severity: enum.SeverityInfo, -}) -``` - -- **OTP W**G6 ơBTTL 5minBbcrypt xs]sX^BoNo 60sB@ challenge 5 -- **Rate Limit**G - - `verify:rate:{tenant}:{uid}:{kind}` SETNX TTL=60s]oO@^ - - `verify:daily:{tenant}:{uid}:{kind}` INCR TTL=24h]WAw] 10 ^ -- **Audit**GStart / Confirm i audit log]Notification ۤv]|OeFAA̤ɡ^ -- **Provider vT member Ҳ**G SendGrid SESBTwilio SNS u `etc/gateway.yaml` P library @ - -### 5.6 Step-up MFA]wMGҥΡ^ - -**γ~**GI~Ⱦާ@eGҡAP ZITADEL MFA **N**C - -#### I Action M]enum^ - -| Action | ؼ API | -|--------|---------| -| `change_business_email` | `PATCH /members/me/business-email` | -| `change_business_phone` | `PATCH /members/me/business-phone` | -| `delete_member` | `DELETE /members/me` | -| `tenant_admin_force_status` | `PATCH /members/:uid/status`]޲zvLH^| -| `revoke_all_sessions` | `POST /auth/revoke-all` | -| `disable_totp` | `DELETE /members/me/totp` | - -> i tenant zL]w[զWF쪩 platform-wide enumATNrC - -#### Step-up qD]wM^ - -uǡG**TOTP > SMS > Email** - -| qD | | u | -|------|------|---------| -| **TOTP**]Google Authenticator^ | ϥΪ̤w `enroll_totp` ]5.8^ | ̿~ providerB|Q SIM swapBLWeBs | -| **SMS** | `BusinessPhoneVerified = true` | email YɡBQdI | -| **Email** | `BusinessEmailVerified = true` | ƳqD | - -Start ɥ `StepUpUseCase` ̨ϥΪ̪ADqDFYϥΪ̭nDLqD]pQ TOTP^ib request a `prefer_channel` мgAݸӳqDwҡC - -#### y{ - -``` -1. Client POST /auth/step-up/start { action, prefer_channel?: "totp" } - - ѪRϥΪ̤wiγqDFDuqD - - Y totpGH OTPA^ challenge_idFcode ѨϥΪ̱q app - - Y sms/emailGͦ 6 X OTPBbcrypt xsBzL NotifierUseCase.Send HX - { challenge_id, channel: "totp"|"sms"|"email", expires_in: 300 } - -2. Client POST /auth/step-up/confirm { challenge_id, code, action } - - totpGmember.TOTPUseCase.VerifyCode(uid, code, window=1) - - sms/emailGbcrypt challenge codeF INCR AttemptCnt - - \ auth.StepUpTokenUseCase.Issue(tenant, uid, action) u JWT - { step_up_token, token_type: "step_up", expires_in: 300 } - -3. Client PATCH /members/me/business-email { ... } - Header: X-Step-Up-Token: - - Logic hG - a. Casbin enforce qL]v^ - b. StepUpTokenUseCase.Verify(token, expectedAction="change_business_email", tenant, uid) - c. SETNX auth:stepup:used:{jti}=1AwιL ڵ - d. ~޿ -``` - -#### uI - -- Logic huG**Casbin allow **A step-upFh -- Header W١G`X-Step-Up-Token` -- Ѧ^ǡG`403 step_up_required` + `{ required_action: "change_business_email", available_channels: ["totp","sms"] }`Aeݨ̦ step-up y{ - -### 5.7 bRPΦWơ]wM^ - -``` -T0: DELETE /api/v1/members/me (Step-up: delete_member) - 1. status = deleted, deleted_at = now - 2. auth.RevokeAllForUser]INCR auth_gen + jti pair ¦W^ - 3. ZITADEL Mgmt.DeactivateUser - 4. M member:profile / member:sub cache - 5. audit log (actor, ip, ua, step_up_jti) - -T+30 : cron `member_anonymize_worker` - ΦW]мg hash ΩTw placeholder^: - ZitadelEmail "deleted:{uid}@anonymized.local" - DisplayName "Deleted User" - Avatar "" - Phone "" - BusinessEmail "" - BusinessPhone "" - BusinessEmail/PhoneVerified false - TOTPSecretCipher "" - TOTPBackupCodesHash nil - external_id, ldap_dn "" - zitadel_sub "deleted:{uid}" # identities ߤ@ - Od]i / fpΡ^: - tenant_id, uid, status=deleted, deleted_at, anonymized_at, created_at - g audit log: action=member.anonymized -``` - -- **if**F30 Ѥiѯ admin ٭]`status=deleted active`A_ cacheA ZITADEL bݥtҥΡ^ -- audit log ΦWƼvT]actor uid OdAKl^ -- ΦWƫ SCIM `Users.{id}` id]^ `active=false` + ΦW payload^A^ 404AH client reconciliation - -### 5.8 TOTP]Authenticator AppAwMGҥΡ^ - -~ȯ TOTPAGateway **ۤvs secret**AP ZITADEL TOTP **W**]ӿW߸jwAϥΪ̭ setup ݦU@ QR^C - -> }HZITADEL TOTP OnJΡBsecret b ZITADELFGateway step-up TOTP Ω~Ⱦާ@Bsecret b GatewayAקK Gateway ZITADEL pƪ̿PXC - -#### Member ]ɥR 5.4^ - -```go -type Member struct { - // ... J - TOTPEnrolled bool - TOTPSecretCipher string // AES-GCM(secret, KEK)AAES-256FKEK KMS / secret manager - TOTPEnrolledAt int64 - TOTPBackupCodesHash []string // bcrypt(code)A10 դ@ʳƴXAιLYٰ -} -``` - -> Secret ٥[KxsA**T**Xγ base32CKEK KMS / VaultFrotation ɳv re-encrypt]I worker^C - -#### UseCase ]ɥR 5.2^ - -```go -type TOTPUseCase interface { - // secret + otpauth URL + 10 backup codes] enrollF|ҥΡ^ - StartEnroll(ctx context.Context, tenantID, uid string) (*EnrollStartDTO, error) - // ϥΪ̱ QRBJĤ@ code T{ TOTPEnrolled = true - ConfirmEnroll(ctx context.Context, tenantID, uid, code string) ([]string, error) // ^ backup_codes]XAu^@^ - // step-up ΡG@ code]t backup code^ - VerifyCode(ctx context.Context, tenantID, uid, code string) error - // Ѱjw] step-up = disable_totp^ - Disable(ctx context.Context, tenantID, uid string) error - // s backup codes] step-up^ - RegenerateBackupCodes(ctx context.Context, tenantID, uid string) ([]string, error) -} -``` - -#### y{ - -``` -A. Enroll - Client POST /api/v1/members/me/totp/enroll-start - 1. Yw TOTPEnrolled = true 409 already_enrolled - 2. ͦ 32-byte random secret base32 - 3. otpauth_url = "otpauth://totp/{Issuer}:{tenant_slug}:{uid}?secret={base32}&issuer={Issuer}&algorithm=SHA1&digits=6&period=30" - 4. Ȧs Redis]J MongoAקKb secret ^: - totp:enroll:{tenant}:{uid} = {secret_cipher} TTL 10min - { otpauth_url, qr_png_base64 } - - Client POST /api/v1/members/me/totp/enroll-confirm { code } - 1. q Redis Ȧs secret - 2. VerifyTOTP(secret, code, window=1) ѫh 400 invalid_code - 3. member.TOTPSecretCipher = secret_cipher - member.TOTPEnrolled = true - member.TOTPEnrolledAt = now - 4. ͦ 10 backup code (random hex)Bbcrypt s TOTPBackupCodesHash - 5. DEL totp:enroll:* - 6. audit log - { backup_codes: [...10 թXAȦ@^] } - -B. Verify]step-up @Ρ^ - StepUpUseCase.Confirm G - VerifyTOTP(decryptedSecret, code, window=1) OR matchBackupCode(code) - Y backup code R q TOTPBackupCodesHash ӵ]榸ʡ^ - -C. Disable - Client DELETE /api/v1/members/me/totp - Header: X-Step-Up-Token: - 1. M TOTPSecretCipherBTOTPEnrolled=falseBTOTPBackupCodesHash=nil - 2. audit log -``` - -#### TOTP tkPѼ - -- **RFC 6238**]SHA1 / 30s period / 6 digits^Aۮe Google AuthenticatorBAuthyB1PasswordBMicrosoft Authenticator -- `window = 1`G\e@ 30s ϶AeԮ} -- Replay O@G\ϥΪ `(uid, code, timestep)` gJ `totp:used:{tenant}:{uid}:{timestep}` SETNX TTL=90sFP@ code GX{ -- Backup codeG10 աB12 r hex]48-bit entropy^Bbcrypt cost 10BX enroll ɦ^Ǥ@ - -#### API]ɥR 7.2^ - -| Method | Path | | Step-up | -|--------|------|------|---------| -| POST | `/api/v1/members/me/totp/enroll-start` | otpauth URL + QR | X | -| POST | `/api/v1/members/me/totp/enroll-confirm` | Ĥ@ codeAҥ + ^ backup codes | X | -| GET | `/api/v1/members/me/totp` | TOTP A]enrolled? backup Ѿlơ^ | X | -| POST | `/api/v1/members/me/totp/backup-codes` | backup codes | ? `disable_totp` | -| DELETE | `/api/v1/members/me/totp` | Ѱjw | ? `disable_totp` | - -### 5.9 UseCase sƥܨ - -> i atomic primitives b **logic h** զX覡CB2C U / nJ **w@** `internal/logic/auth/`F [auth-unified-registration.md](./auth-unified-registration.md)C - -#### Case AGx͵U + Email OTP ҡ]**w@**G`RegisterLogic` / `RegisterConfirmLogic`^ - -```go -// HTTP: POST /auth/register Logic sơ]Kn^ -// 1) invite consume]Y RequireInviteCode^ -// 2) zitadel.CreateHumanUser -m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{ - TenantID: tenantID, Email: email, DisplayName: name, ZitadelUserID: zitadelSub, -}) -// 3) registration metadata.Record]channel=email^ -chal, plain, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ - TenantID: tenantID, UID: m.UID, Purpose: OTPPurposeRegistrationEmail, Target: email, -}) -notifier.Send(ctx, &SendRequest{ Kind: NotifyVerifyRegistrationEmail, Data: map[string]any{"code": plain, ...} }) -// HTTP: POST /auth/register/confirm -_ = mOTP.Verify(ctx, &VerifyOTPRequest{ ... Purpose: OTPPurposeRegistrationEmail }) -_ = mLifecycle.Activate(ctx, tenantID, m.UID) -// auth.IssuePair { access_token, refresh_token } -``` - -#### Case BGOIDC]Social / ZITADEL Hosted UI^nJ X OTP - -```go -m, _ := mProv.EnsureFromOIDC(ctx, &EnsureFromOIDCRequest{ - TenantID: tenantID, - ZitadelSub: claims.Sub, - Email: claims.Email, - EmailVerified: claims.EmailVerified, - DisplayName: claims.Name, -}) -// activeF auth.IssueTokenPair -``` - -#### Case CGLDAP IdP nJ JIT X OTP - -```go -m, _ := mProv.EnsureFromLDAP(ctx, &EnsureFromLDAPRequest{ - TenantID: tenantID, ExternalID: ldapUUID, LDAPDN: dn, - Username: username, Email: email, DisplayName: name, - Groups: groups, Source: RoleSourceLDAPJIT, -}) -``` - -#### Case DGSCIM Create User X OTP - -```go -m, _ := mProv.EnsureFromSCIM(ctx, &EnsureFromSCIMRequest{ - TenantID: tenantID, ExternalID: scimExternalID, - UserName: username, Email: email, Active: true, RawPayload: rawJSON, -}) -``` - -#### Case EGwnJ user j~ email]atomic vs composite^ - -```go -// | 1G atomic]ӱɥΡ^ -chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ - TenantID: tenantID, Purpose: OTPPurposeBusinessEmail, Identifier: uid, -}) -notifier.Send(ctx, &SendRequest{ - Channel: ChannelEmail, Kind: NotifyVerifyBusinessEmail, - Target: newEmail, Data: map[string]any{"code": chal.Code}, - IdempotencyKey: chal.ChallengeID, DoNotPersistBody: true, -}) -_ = mOTP.Verify(ctx, &VerifyOTPRequest{ - TenantID: tenantID, ChallengeID: chal.ChallengeID, - Code: userCode, Purpose: OTPPurposeBusinessEmail, -}) -_ = mProfile.SetBusinessEmailVerified(ctx, tenantID, uid, newEmail) - -// | 2G composite]²污poӴNn^ -chal, _ := mVerification.StartEmailVerify(ctx, tenantID, uid, newEmail) -// ... -_ = mVerification.ConfirmEmailVerify(ctx, tenantID, uid, chal.ChallengeID, userCode) -``` - -> C atomic ʧ@WߥiIsBW auditBWߥѭաCLogic ۦMwզXPǡC - ---- - -## 6. permission Ҳա]B2B ۩wqAѦ permission-server^ - -|G`internal/model/permission/` - -> `l [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server) wҪ]pG**Casbin + Redis RBAC**B**Permission Tree]l~ӡ^**B**HTTP Path/Method jw**C -> **P permission-server t**GToken ño//¦W沾 Gateway `auth` ҲաF`ClientID` אּ `tenant_id`F䴩h B2B U۩wq RoleC - -### 6.1 ]pؼ - -| O | | -|------|------| -| **Permission Tree** | v]x seed^Al`I~ӡF`Ihl`Ii | -| **Casbin RBAC** | H `(tenant_id, role_key, http_path, http_method)` API vFpath 䴩 `keyMatch2` UΦr | -| **B2B ۩wq Role** | Cӯإߦۭq RoleAq Catalog **Ŀ** Permission]i۳ Permission r^ | -| **UserRole** | + uid + roleF䴩h]H immutable role key Casbin subject^ | -| **RolePermission** | Ŀlvɦ۰ʸɻv ID]u permission-server `getFullParentPermissionIDs`^ | -| **Policy PB** | MongoDB Casbin Policy RedisFw `LoadPolicy` + ܧIJo reload | -| **~Mg** | ZITADEL Role / LDAP Group / SCIM Group ᤺ Role.Key | -| **ӲɫXi** | P@ API i `.plain_code` lv]pXdߡ^Au³]p | - -### 6.2 P app-cloudep-permission-server - -| permission-server | Gateway permission Ҳ | Ƶ | -|-------------------|---------------------------|------| -| `TokenService` | **`auth` Ҳ** | JWT A permission-server | -| `PermissionService`]š^ | HTTP API | b Gateway S | -| `entity.Permission` | u + `tenant_id` AΡ] Catalog^ | Permission x | -| `entity.Role.ClientID` | `Role.TenantID` | j | -| `entity.Role.UID` | `Role.CreatorUID` | إߪ̡Ai | -| `entity.Role.Name` | `Role.DisplayName` | ܦW١AiW | -| X | `Role.Key` | **Casbin policy role **A᤺ߤ@Bi | -| `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | u | -| `PermissionTree` | `usecase/permission_tree.go` | u | -| `AdminRoleUID` / `GodDog` | `PlatformAdminRoleKey` + allowlist | xWź޲z bypassA audit | -| `permission.Type` | `enum.PermissionType` | `BackendUser` / `FrontendUser` | - -### 6.3 ֤߷ - -``` -Permission]^ xwqAt name / http_path / http_method / parent / status / type -Role]۩wq^ إߪFdisplay_name iAkey iAp sales_supervisorBtenant_admin -RolePermission Role ? Permission ID hhFĿɦ۰ʸɤ`I -UserRole uid ? RoleF@ user ih role -RoleMapping ~ Group/Role RoleID / Role.Key -Casbin Policy p, {tenant_id}, {role_key}, {http_path}, {http_method}, {permission.Name} -``` - -### 6.4 Permission Entity] Catalog^ - -u permission-server `entity.Permission` cAMongoDB collectionG`permission`C - -```go -type Permission struct { - ID primitive.ObjectID - Parent string // v ID]ObjectID hex^F = root - Name string // ߤ@yNWAdot notationAp member.info.select - HTTPMethods string // Ȧp "GET"A regex p "GET|POST|PATCH"F`I - HTTPPath string // p /api/v1/members/*]keyMatch2 pattern^F`I - Status enum.Status // open | close - Type enum.PermissionType // backend_user | frontend_user]x / ex^ - CreateAt int64 - UpdateAt int64 -} -``` - -> **`Permission.Name` @إߤiW**]Q RolePermissionBUI i18n BCasbin policy.name ޥΡ^C -> o `status=close`FsW٥tطs leafCRWnƾE}C -> **`HTTPPath` **GקKr `*`FUθ|nTХX귽ڡAҦp `/api/v1/members/*`AT `/api/v1/*` s pattern] keyMatch2 gR^C - -#### RWWh]dot notationAP permission-server @P^ - -``` -{domain}.{module}.{action} -{domain}.{module}.{action}.{variant} # p .plain_code -``` - -#### Permission Tree dҡ]seed ס^ - -``` -member.info.management # @šG|T޲z]AL HTTP^ -uww member.basic.info # GšG¦T -x uww member.info.select # GET /api/v1/members/me -x uww member.info.update # PATCH /api/v1/members/me -x |ww member.info.select.plain_code # GET /api/v1/members]X^ -uww member.admin.list # GET /api/v1/members -uww member.admin.read # GET /api/v1/members/:uid -uww member.admin.update # PATCH /api/v1/members/:uid -|ww member.admin.status # PATCH /api/v1/members/:uid/status - -permission.role.management # @šGv޲z -uww permission.role.read # GET /api/v1/permissions/roles -uww permission.role.write # POST/PUT/DELETE roles -uww permission.assign.write # POST/DELETE user roles -|ww permission.catalog.read # GET /api/v1/permissions/catalog - -tenant.management -uww tenant.read -uww tenant.ldap.write -|ww tenant.sync.trigger - -scim.management -uww scim.users.write -|ww scim.groups.write - -system.management # x -|ww system.tenant.create -``` - -> **`I**]L `http_path`^ UI 𪬤ĿF**`I**~gJ Casbin PolicyC -> sW Permission x seed migrationF**i**ۦsW Permission W١C - -#### Permission Tree 欰]u permission-server^ - -1. **`filterOpenNodes`**G`I `status=close` ʤl𤣥i -2. **`getFullParentPermissionIDs`**GĿlv ۰ʥ[JҦ`I ID -3. **`getFullParentPermission`**Gd Role v ^ǧt`I permission name status map]ѫe UI^ - -### 6.5 Role Entity]B2B ۩wq^ - -```go -type Role struct { - ID primitive.ObjectID - TenantID string // ID]= ClientID^ - Key string // immutable role keyA᤺ߤ@FCasbin enforce Φ - DisplayName string // ܦW١AiW - CreatorUID string // إߪ uid]= Role.UIDAi^ - Status enum.Status // open | close - IsSystem bool // t seed w]AB2B i Permission iR Owner - CreateAt int64 - UpdateAt int64 -} -// Index: { tenant_id, key } unique -``` - -> **`Role.Key` Wd**G -> - 榡G`^[a-z][a-z0-9_]{1,63}$` -> - ᤺ߤ@Fإ߫**iק** -> - T `system.` / `platform_` r]Odx role^ -> - rename `DisplayName`AvT UserRoleBRoleMappingBCasbin policy PJ token - -#### B2B ۩wqWh - -1. i **CRUD** ۭq Role]`is_system=false`^ -2. t seed w] Role]`is_system=true`^iק Permission XA**tenant_owner iR** -3. Role jw Permission O Catalog `status=open` `I -4. **i**Ŀ `system.*` v]Dxt}ҡ^ -5. ܤ֫Od@ Role t `permission.role.write`AקK - -#### w] Role ҪO]إ B2B tenant seed^ - -| Key | DisplayName | w]Ŀ]Permission Name^ | -|------|------|----------------------------| -| `tenant_owner` | ֦ | `system.*` ~ open `I | -| `tenant_admin` | ޲z | member.*, permission.*, tenant.*, scim.* | -| `member_manager` | |޲z | member.admin.list, member.admin.read, member.admin.status | -| `member` | @| | member.info.select, member.info.update | -| `viewer` | Ū | member.info.select | - -B2B ޲zdҡG - -``` -إ RoleGsales_supervisor -ĿGmember.admin.list, member.admin.read -GPOST /permissions/users/{uid}/roles { "role_id": "..." } - RolePermission.Create getFullParentPermissionIDs ۰ʸ parent - LoadPolicy s Casbin -``` - -### 6.6 UserRole / RolePermission - -```go -type UserRole struct { - TenantID string - UID string - RoleID string // Role._id hex - Source enum.RoleSource // manual | zitadel | ldap | scim - CreateAt int64 - UpdateAt int64 -} -// Index: { tenant_id, uid, role_id } unique -// Index: { tenant_id, uid } - -type RolePermission struct { - TenantID string - RoleID string - PermissionID string - CreateAt int64 - UpdateAt int64 -} -// Index: { tenant_id, role_id, permission_id } unique -``` - -> permission-server UserRole @ user @ role]Update л\^Fs]p**䴩h**AMiddleware C immutable role key Casbin enforceA@ allow YqLC - -### 6.7 Casbin RBAC]֤߱v^ - -#### ҫ `etc/rbac.conf`]Gateway h᪩^ - -```ini -[request_definition] -r = tenant, role, path, method - -[policy_definition] -p = tenant, role, path, methods, name - -[policy_effect] -e = some(where (p.eft == allow)) - -[matchers] -m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) -``` - -- **`keyMatch2`**G䴩 `/api/v1/members/*` U path -- **`regexMatch`**G䴩 `GET|POST` h method gbP@ policy -- **SuperAdmin bypass**Gb Casbin matcherF Middleware platform role / allowlist uAügJ audit log - -#### Policy J]`RBACUseCase.LoadPolicy`^ - -``` -1. permissionRepo.GetAll GeneratePermissionTree filterOpenNodes -2. roleRepo.All(tenant_id) C role rolePermissionRepo.Get -3. C (role, permission) Y http_path + http_method DšG - enforcer.AddPolicy(tenantID, role.Key, permission.HTTPPath, permission.HTTPMethods, permission.Name) -4. adapter.SavePolicy(tenant_id) Redis List]tenant-scoped casbin rules^ -5. enforcer.LoadPolicy() -``` - -#### vˬd]`RBACUseCase.Check`^ - -```go -// JGtenantID, roleKey, requestPath, requestMethod -ok, policy, err := enforcer.EnforceEx(tenantID, roleKey, path, method) - -// ^ CheckRolePermissionStatusG -// Allow: bool -// PermissionName: string // R permission.Name -// PlainCode: bool // O_ .plain_code lv]GET B~d^ -``` - -#### Policy PB - -| IJo | ʧ@ | -|------|------| -| RolePermission ܧ | tenant `LoadPolicy` + v֨ | -| Permission status ܧ]x^ | `LoadAllPolicies` + v֨ | -| w cron]p 5min^ | `SyncPolicy` © | -| Gateway Ұ | l `LoadPolicy` | - -Redis xs Casbin rulesG`permission:casbin:rules:{tenant_id}`]List of JSON `rbac.Rule`^CqJɥiy tenant-scoped keysAΥ repository MongoDB role/permission ءC - -### 6.8 UseCase - -```go -// --- Casbin v]֤ߡ^--- -type RBACUseCase interface { - Check(ctx context.Context, req *CheckRequest) (*CheckResult, error) - LoadPolicy(ctx context.Context, tenantID string) error - LoadAllPolicies(ctx context.Context) error - SyncPolicy(ctx context.Context, interval time.Duration) -} - -type CheckRequest struct { - TenantID string - UID string - RoleKey string // immutable Role.Key - Path string // ڽШD path - Method string // HTTP method -} - -type CheckResult struct { - Allow bool - PermissionName string - PlainCode bool - MatchedRole string -} - -// --- Permission Catalog]xš^--- -type PermissionUseCase interface { - All(ctx context.Context, status *enum.Status) ([]PermissionDTO, error) - FilterAll(ctx context.Context) ([]PermissionDTO, error) // 𪬹Lo open `I - Insert(ctx context.Context, req *CreatePermissionRequest) error // x Admin - Update(ctx context.Context, id string, req *UpdatePermissionRequest) error -} - -// --- Role]šAB2B ۩wq^--- -type RoleUseCase interface { - List(ctx context.Context, req *ListRolesRequest) ([]RoleDTO, int64, error) - All(ctx context.Context, tenantID string) ([]RoleDTO, error) - GetByID(ctx context.Context, tenantID, id string) (*RoleDTO, error) - Create(ctx context.Context, req *CreateRoleRequest) error - Update(ctx context.Context, id string, req *UpdateRoleRequest) error - Delete(ctx context.Context, tenantID, id string) error -} - -// --- RolePermission --- -type RolePermissionUseCase interface { - Get(ctx context.Context, tenantID, roleID string) (enum.Permissions, error) // name open/close - Replace(ctx context.Context, tenantID, roleID string, permNames []string) error // qN -} - -// --- UserRole --- -type UserRoleUseCase interface { - GetByUID(ctx context.Context, tenantID, uid string) ([]UserRoleDTO, error) - GetRoleKeys(ctx context.Context, tenantID, uid string) ([]string, error) // Middleware ΡA cache - Assign(ctx context.Context, tenantID, uid, roleID string, source enum.RoleSource) error - Revoke(ctx context.Context, tenantID, uid, roleID string) error - Replace(ctx context.Context, tenantID, uid string, roleIDs []string, source enum.RoleSource) error // qNu sourcev -} - -// --- ~Mg --- -type RoleMappingUseCase interface { - List(ctx context.Context, tenantID string) ([]RoleMappingDTO, error) - Upsert(ctx context.Context, req *UpsertRoleMappingRequest) error - Delete(ctx context.Context, tenantID, id string) error - SyncFromZitadelClaims(ctx context.Context, req *SyncFromZitadelRequest) error - SyncFromScimGroup(ctx context.Context, req *SyncFromScimGroupRequest) error - SyncFromLDAPGroups(ctx context.Context, req *SyncFromLDAPGroupsRequest) error -} - -// --- EXdߡ]eݵ?^--- -type AuthorizationQueryUseCase interface { - GetMyPermissions(ctx context.Context, tenantID, uid string) (enum.Permissions, error) - GetMyRoles(ctx context.Context, tenantID, uid string) ([]string, error) -} -``` - -> **󯲤ᨾb**GҦ mutation usecase]Role*, RolePermission*, UserRole*, RoleMapping*^iJɥ target ID ݩ `tenantID`Frepository dߤ@߱a `{tenant_id, _id}`A䤣^ `ErrRoleNotInTenant` / `ErrUserRoleNotInTenant`C -> Logic h**T** path `:id` usecase Ӥa `tenant_id`C - -### 6.9 Middleware vy{ - -``` -Request]JwtRevokeMiddleware wL JWT + auth_gen^ - 1. ctx.tenant_id, ctx.uid - 2. userRoleUC.GetRoleKeys []Role.Key] perm:user_roles cache^ - 3. C roleKey enforce(tenantID, roleKey, path, method)F - EXҦ allow G []CheckResult - 4. YL allow 403 Forbidden - 5. EXWhG - - PermissionNames = Ҧ allow R permission.Name]h^ - - PlainCode = CөR permissionAB~ enforce - (permission.Name + ".plain_code") F@qL true - 6. `J ctx.permission_names, ctx.plain_code -``` - -> **PlainCode @**G`*.plain_code` P@ leaf @˼gJ Casbin policyFCheck ɥD permission RAΦP@ `(tenantID, roleKey, path, method)` A@a `.plain_code` EnforceExCS plain_code falseC -> Logic hŪ `ctx.plain_code` MwO_^ǩXC - -> **Platform Admin bypass** `JwtRevokeMiddleware` 0 BBz] 4.6^AioӬy{C - -### 6.10 ~ Group / Role Mg - -```go -type RoleMapping struct { - TenantID string - ExternalSource enum.RoleSource // zitadel | ldap | scim - ExternalKey string // ZITADEL role / LDAP group DN / SCIM group id - InternalRoleID string // Role._id hex - InternalRoleKey string // denormalized Role.KeyAKd߻Pfp -} -// Index: { tenant_id, external_source, external_key } unique -``` - -| ӷ | ExternalKey d | Mg | -|------|------------------|--------| -| ZITADEL | `org_admin` | `tenant_admin` | -| LDAP (AD) | `CN=CloudEP-Admins,OU=Groups,DC=acme,DC=com` | ۭq Role.Key | -| LDAP (OpenLDAP) | `cn=admins,ou=groups,dc=acme,dc=com` | ۭq Role.Key | -| SCIM Group | `group-uuid-xxx` | ۭq Role.Key | - - B2B ޲zbx]w]ݩR `permission.role.write` API^C - -#### ~ӷPBWh]קK~ manual ^ - -`SyncFromZitadelClaims` / `SyncFromScimGroup` / `SyncFromLDAPGroups` @ߥH **`source` **qNG - -``` -UserRoleUC.Replace(tenantID, uid, roleIDs, source = zitadel) - DELETE user_roles WHERE tenant_id=? AND uid=? AND source='zitadel' - INSERT s roleIDs]source='zitadel'^ - source='manual' / 'scim' / 'ldap' vT -``` - -> ӷĬhGUserRole uövA@ source role YͮġFrevoke w sourceC - -### 6.11 vܧͮ - -| ƥ | ʧ@ | -|------|------| -| RolePermission Create/Delete | `LoadPolicy(tenant_id)` + `perm:role_perms:*` ֨ | -| Role Create/Update/Delete | `LoadPolicy(tenant_id)` | -| UserRole Assign/Revoke | **`INCR auth:gen`** + `LoadPolicy(tenant_id)` | -| SCIM / LDAP Group ܧ | s user_roles `LoadPolicy` + **`INCR auth_gen`** | -| Permission status ܧ]x^ | `LoadAllPolicies()` + v֨ġFYܧvTnJAA batch `INCR auth_gen` | - -#### h Pod PB]wM^ - -``` -Channel: casbin:reload -Payload: { "tenant_id": "xxx", "ts": 1716120000000 } # tenant_id == "*" Nq -``` - -- **YɳqD**GPub/Sub - - WriterGC `LoadPolicy(tenant_id)` `PUBLISH casbin:reload {tenant_id}` - - SubscriberGC pod Ұʮ `SUBSCRIBE`FbO餤 reload tenant policy -- **©**GC pod ҰʱƵ{ `5min` q `LoadAllPolicies()`F pub message |]pod ҰBRedis suݰʡ^ -- ****Greload γ@ mutex per tenantFPɬqh message uIJo@ IO -- **Ұ**Gpod Ұʥ@ `LoadAllPolicies()`AA}l SUBSCRIBE -- ]wG`Permission.PolicySyncInterval: 5m`B`Permission.PolicyReloadChannel: casbin:reload` - -### 6.12 B2C vs B2B v]wM^ - -| | Role ۩wq | Permission Ŀ | API | -|----------|-------------|-----------------|----------| -| **B2C** | **i**]Ū seed ҪO^ | TwAi | T `POST/PUT/DELETE /permissions/roles*` | -| **B2B** | **۩wq** | q Catalog ۥѤĿ | permission API | -| **Hybrid** | tenant.type P_ | B2B qi۩wq | middleware ˬd tenant | - -B2C إ߮ɥu seed Tw Role]p `member`B`viewer`^A**** Role CRUD P Permission Ŀ API]Casbin J seed G^C - ---- - -## 7. API W - -ɮסG`generate/api/` - -### 7.1 auth.api]} / JWT API өw^ - -> **w@**]2026-05-21^GUuAv?`FШD/^ [auth-unified-registration.md 4](./auth-unified-registration.md#4-api-Wgenerateapiauthapi) P `generate/api/auth.api`C - -| Method | Path | | Ųv | A | -|--------|------|------|------|------| -| POST | `/api/v1/auth/register` | Email + KXU]ZITADEL + member + registration OTP^ | } | ? | -| POST | `/api/v1/auth/register/confirm` | T{ registration OTP CloudEP JWT | } | ? | -| POST | `/api/v1/auth/register/resend` | H registration OTP | } | ? | -| POST | `/api/v1/auth/register/social/start` | Social **U** start]t invite session^ | } | ? | -| GET | `/api/v1/auth/register/social/callback` | Social **U** OAuth callback JWT | } | ? | -| POST | `/api/v1/auth/login` | Email + KXnJ]ZITADEL ROPG JWT^ | } | ? | -| POST | `/api/v1/auth/login/social/start` | Social **nJ** start]L invite^ | } | ? | -| GET | `/api/v1/auth/login/social/callback` | Social **nJ** OAuth callback JWT | } | ? | -| POST | `/api/v1/auth/token/refresh` | s JWT | }]a refresh^ | ? | -| POST | `/api/v1/auth/token/exchange` | ZITADEL `id_token` CloudEP JWT]~ SSO^ | } | ? | -| POST | `/api/v1/auth/logout` | nX]jti ¦W^ | JWT | ? | -| POST | `/api/v1/auth/revoke-all` | MPۤvҦ session]INCR auth_gen^ | JWT + Step-up `revoke_all_sessions` | W | -| POST | `/api/v1/auth/step-up/start` | Ұ step-up MFAAH OTP | JWT | W | -| POST | `/api/v1/auth/step-up/confirm` | T{ OTP ñou `step_up_token` | JWT | W | - -### 7.2 member.api] JWT + Casbin^ - -| Method | Path | Casbin R Permission]ܨҡ^ | Step-up | -|--------|------|-------------------------------|---------| -| GET | `/api/v1/members/me` | `member.info.select` | X | -| PATCH | `/api/v1/members/me` | `member.info.update` | X | -| PATCH | `/api/v1/members/me/business-email` | `member.info.update` | ? `change_business_email` | -| PATCH | `/api/v1/members/me/business-phone` | `member.info.update` | ? `change_business_phone` | -| DELETE | `/api/v1/members/me` | `member.info.delete` | ? `delete_member` | -| POST | `/api/v1/members/me/verifications/email/start` | `member.info.update` | X | -| POST | `/api/v1/members/me/verifications/email/confirm` | `member.info.update` | X | -| POST | `/api/v1/members/me/verifications/phone/start` | `member.info.update` | X | -| POST | `/api/v1/members/me/verifications/phone/confirm` | `member.info.update` | X | -| GET | `/api/v1/members/me/totp` | `member.info.select` | X | -| POST | `/api/v1/members/me/totp/enroll-start` | `member.info.update` | X | -| POST | `/api/v1/members/me/totp/enroll-confirm` | `member.info.update` | X | -| POST | `/api/v1/members/me/totp/backup-codes` | `member.info.update` | ? `disable_totp` | -| DELETE | `/api/v1/members/me/totp` | `member.info.update` | ? `disable_totp` | -| GET | `/api/v1/members` | `member.admin.list` | X | -| GET | `/api/v1/members/:uid` | `member.admin.read` | X | -| PATCH | `/api/v1/members/:uid` | `member.admin.update` | X | -| PATCH | `/api/v1/members/:uid/status` | `member.admin.status` | ? `tenant_admin_force_status` | - -> v **Casbin path + method** MwADwsX permission rC -> Step-up 欰?̻ݦb Header a `X-Step-Up-Token`AB token claim `action` PC action @P] 5.6^C - -### 7.3 permission.api] JWT + Casbin^ - -| Method | Path | | -|--------|------|------| -| GET | `/api/v1/permissions/catalog` | Permission Tree]open `I^ | -| GET | `/api/v1/permissions/me` | eϥΪ̪ permission name status map | -| GET | `/api/v1/permissions/roles` | CX Role | -| POST | `/api/v1/permissions/roles` | إ Role]B2B^ | -| PUT | `/api/v1/permissions/roles/:id` | s Role | -| DELETE | `/api/v1/permissions/roles/:id` | R Role | -| GET | `/api/v1/permissions/roles/:id/permissions` | o Role Ŀ諸 Permission | -| PUT | `/api/v1/permissions/roles/:id/permissions` | qN Role Ŀ `{ "permission_names": [...] }`]PermissionTree + parent^ | -| GET | `/api/v1/permissions/users/:uid/roles` | dϥΪ̨ | -| POST | `/api/v1/permissions/users/:uid/roles` | Role `{ "role_id": "..." }` | -| DELETE | `/api/v1/permissions/users/:uid/roles/:role_id` | MP Role | -| GET | `/api/v1/permissions/role-mappings` | ~ Group MgC | -| PUT | `/api/v1/permissions/role-mappings` | sW/sMg | -| POST | `/api/v1/permissions/policy/reload` | IJo LoadPolicy]x Admin^ | - -### 7.4 tenant.api]x / Admin^ - -| Method | Path | Casbin R Permission]ܨҡ^ | -|--------|------|-------------------------------| -| POST | `/api/v1/admin/tenants` | `system.tenant.create` | -| GET | `/api/v1/admin/tenants/:tenant_id` | `tenant.read` | -| PUT | `/api/v1/admin/tenants/:tenant_id/ldap` | `tenant.ldap.write` | -| POST | `/api/v1/admin/tenants/:tenant_id/directory-sync` | `tenant.sync.trigger` | - -### 7.5 scim.api]SCIM Bearer TokenAD JWT^ - -**wM**GH **`tenant_id`** path Ѽơ]ΤlW^ - -``` -/scim/v2/tenants/{tenant_id}/Users -/scim/v2/tenants/{tenant_id}/Groups -/scim/v2/tenants/{tenant_id}/ServiceProviderConfig -/scim/v2/tenants/{tenant_id}/Schemas -``` - -{ҡG`Authorization: Bearer {tenant_scim_token}`]hash s tenant ]w^ - -- `{tenant_id}` = ZITADEL `org_id`AP JWT `tenant_id` @P -- SCIM ШD CloudEP JWTFv tenant SCIM token + i Casbin Ӥ - ---- - -## 8. Middleware - -### 8.1 @O@ API - -**ثew@]member Ҳա^G** - -``` -Request - CloudEPJWT middleware]i Bearer access JWT `J tenant_id + uid context^ - member handlerGY context L actorAfallback dev headers X-Tenant-ID + X-UID]}o^ - handler logic usecase -``` - -**ؼЧ]Casbin / permission ҲմN^G** - -``` -Request - go-zero JWT ñ - JwtRevokeMiddleware]jti ¦W + auth_gen^ - TenantContextMiddleware] tenant_id @P^ - CasbinRBACMiddleware]tenant_id role_key path method Allow^ - handler logic usecase -``` - -### 8.2 CasbinRBACMiddleware - -> Platform Admin bypass be@h `JwtRevokeMiddleware` 0 BBz]4.6^ABơC - -```go -// ?N? -roleKeys, _ := userRoleUC.GetRoleKeys(ctx, tenantID, uid) -var hits []rbac.CheckResult -for _, roleKey := range roleKeys { - res, _ := rbacUC.Check(ctx, &rbac.CheckRequest{ - TenantID: tenantID, UID: uid, - RoleKey: roleKey, Path: r.URL.Path, Method: r.Method, - }) - if res.Allow { - hits = append(hits, res) - } -} -if len(hits) == 0 { - httpx.Error(w, forbidden) - return -} - -names, plain := aggregate(hits) // h + PlainCode OR -ctx = withPermissionNames(ctx, names) -ctx = withPlainCode(ctx, plain) -next(w, r) -``` - -### 8.3 SCIM API - -``` -Request - ScimAuthMiddleware]tenant_scim_token^ - TenantContextMiddleware - handler -``` - -### 8.4 Logic hɥRv - -Casbin Bz **API ** vCLogic il[ **귽** P_G - -- `member.info.select` vs dLHGY path t `:uid` B uid callerAݩR `member.admin.read` -- `PlainCode`GLogic Ū `ctx.plain_code`AMwO_^ǩX -- **Step-up u**]I action^G - 1. q Header `X-Step-Up-Token` token - 2. `auth.StepUpTokenUseCase.Verify(token, expectedAction, tenantID, uid)` - - `typ == "step_up"`B`action == expectedAction`B`tenant_id` / `uid` P ctx @PBL - 3. `SETNX auth:stepup:used:{jti}=1`Awsb `403 step_up_replay` - 4. qL ~Ⱦާ@ - 5. `403 step_up_required` + `{ required_action: "" }` - ---- - -## 9. ֤߬y{ - -### 9.1 nJ / - -#### 9.1.1 Email + KXnJ]w@^ - -``` -Client POST /api/v1/auth/login { tenant_slug, email, password } - 1. tenant.ResolveBySlug - 2. zitadel.VerifyPassword]ROPG^ - 3. ѪR id_token / userinfo zitadel sub - 4. member.GetByZitadelUserID member_status == active - 5. auth.IssuePair -Client { access_token, refresh_token, uid } -``` - -#### 9.1.2 ZITADEL id_token ]SSO / clientAw@^ - -``` -Client POST /api/v1/auth/token/exchange { tenant_slug, id_token } - 1. zitadel.VerifyIDToken]JWKS ñ + iss/aud/exp^ - 2. tenant.ResolveBySlug - 3. member.GetByZitadelUserID active - 4. auth.IssuePair -Client { access_token, refresh_token, uid } -``` - -#### 9.1.3 OIDC nJ + JIT]B2B / B2C Hosted UI |A䴩^ - -``` -Client ZITADEL OIDC Login]t LDAP IdP^ -Client POST /auth/token/exchange { tenant_slug, id_token } - 1. zitadel.VerifyIDToken - 2. tenant.ResolveBySlug org_id - 3. member.EnsureFromOIDC uid]p AMEX-10000000^ // Y member sbh JIT - 4. permission.SyncFromZitadelClaims user_roles // W - 5. auth.IssueTokenPair -Client { access_token, refresh_token, uid } -``` - -> **`N**GB2C sU 3.4 Gateway `/auth/register*`F`/auth/token/exchange` Od **wsb member** SSO nJP~ IdPC - -### 9.2 O@ API - -``` -Client GET /api/v1/members/me (Bearer access_jwt) - 1. JWT + ¦W + auth_gen - 2. CasbinRBACMiddleware Check(role, "/api/v1/members/me", "GET") - 3. member.GetByUID -``` - -### 9.3 B2B ۩wq Role + Ŀ Permission - -``` -Tenant Admin PUT /api/v1/permissions/roles/{id}/permissions - { "permission_names": ["member.admin.list", "member.admin.read"] } - RolePermissionUC.Replace]qN^ - PermissionTree.getFullParentPermissionIDs]۰ʸ parent^ - RBACUC.LoadPolicy(tenant_id) + s reload] 6.11^ - -Tenant Admin POST /api/v1/permissions/users/{uid}/roles - { "role_id": "..." } - UserRoleUC.Assign(tenantID, uid, roleID, source=manual) - INCR auth_gen + DEL perm:user_roles cache -``` - -### 9.4 v - -``` -Admin PATCH /api/v1/members/:uid/status { status: "suspended" } - Header: X-Step-Up-Token: - 1. Casbin enforce R member.admin.status - 2. Logic step_up_token + action @P - 3. member.UpdateStatus - 4. auth.RevokeAllForUser]INCR auth:gen:{tenant_id}:{uid}^ -``` - -### 9.5 ~ Email - -``` -Client POST /api/v1/members/me/verifications/email/start { target: "biz@foo.com" } - 1. (i) ˬd target QPL member ϥ - 2. ˬd verify:rate:{tenant}:{uid}:email sb]60s No^ - 3. ͦ 6 X OTP bcrypt s verify:otp:{tenant}:{uid}:email:{challenge_id} TTL 5min - 4. NotificationClient.Email.Send(target, template=VerifyEmail, data={code}) - 5. SETEX verify:rate:{tenant}:{uid}:email 60 - 6. audit log -Client { challenge_id, expires_in: 300 } - -Client POST /api/v1/members/me/verifications/email/confirm { challenge_id, code } - 1. Ū challengeFLΥ 5 ڵ - 2. bcrypt compareF INCR AttemptCnt ڵ - 3. \ member.BusinessEmail = target, BusinessEmailVerified = true, BusinessEmailVerifiedAt = now - 4. DEL challenge - 5. audit log -Client { verified: true } -``` - -> phone y{PWAOTP qD SMS ProviderFtemplate `VerifyPhone`C - -### 9.6 Step-up MFA + ~ Email - -``` -Client POST /api/v1/auth/step-up/start { action: "change_business_email" } - 1. q ctx.uid Ū memberFnD BusinessEmailVerified || BusinessPhoneVerified - 2. qDGu phone]pw verified^_h email - 3. ͦ OTP HX]BJP 9.5^ -Client { challenge_id, channel, expires_in: 300 } - -Client POST /api/v1/auth/step-up/confirm { challenge_id, code, action: "change_business_email" } - 1. bcrypt F challenge.kind == step_up && target == action - 2. auth.StepUpTokenUseCase.Issue(tenant, uid, action) JWT (typ=step_up, action, TTL 5min) -Client { step_up_token, token_type: "step_up", expires_in: 300 } - -Client PATCH /api/v1/members/me/business-email { new_email: "new@foo.com" } - Header: X-Step-Up-Token: - 1. Middleware qL]@ JWT + Casbin^ - 2. Logic step-up u] 8.4^ - 3. ] BusinessEmailVerified = falseABusinessEmail = new_email - 4. IJo 9.5 new_email so OTP]Ϊ^ challenge_id eݡ^ - 5. audit log]t / s emailBstep_up jtiBIPBUA^ -``` - ---- - -## 10. LDAP P SCIM - -### 10.1 T Provisioning | - -| | | A | | -|------|------|------| -| **SCIM ZITADEL Gateway** | HR / Entra ID / Okta | ~eϥΪ | -| **ZITADEL LDAP IdP** | ΤnJ JIT | nJإ member | -| **Directory Sync Worker** | L SCIM AD / OpenLDAP | wɦPB + ¾ | - -### 10.2 LDAP ]w]AD + OpenLDAP^ - -```go -type TenantLDAPConfig struct { - TenantID string - Type string // "ad" | "openldap" - Host string - Port int - UseTLS bool - BaseDN string - BindDN string // encrypted - BindPassword string // encrypted - UserFilter string - GroupFilter string - AttrMap LDAPAttrMap -} - -type LDAPAttrMap struct { - Username string // AD: sAMAccountName / LDAP: uid - Email string // mail - DisplayName string // displayName / cn - Phone string // telephoneNumber - ExternalID string // objectGUID / entryUUID - Groups string // memberOf -} -``` - -### 10.3 SCIM - -- **SCIM `id` = Gateway Member UID**]`AMEX-10000000`^X wMFHŪBtΤ@PAK audit/䴩ed -- SCIM `externalId` = Ȥ IdP / HR tδѪ~ѧO]p Okta user idBEntra object idBemployee id^ -- `externalId` H `{tenant_id, external_id}` idempotent upsert keyFi]ȤݪD Gateway UID -- ZITADEL `sub`BMongo `_id` ~nSFZITADEL `sub` zL SCIM Extension Schema `urn:cloudep:scim:2.0:User:zitadelSub` ѬdߡAK~ troubleshoot -- SCIM Groups PATCH `permission.SyncFromScimGroup` -- SCIM deactivate `member.suspended` + `auth.RevokeAllForUser` - -### 10.4 Directory Sync ~PO@]wM^ - -| | ]w | 欰 | -|------|------|------| -| s䤣~v | `MissingThreshold: 3`]s 3 cron^ | pƩ `members.directory_missing_count`F_Yks | -| 榸ʤW | `MaxChangeRatio: 0.20` | 榸 sync ʶWLӯ active members 20% **j dry-run** + iĵAݤHuT{ | -| p | `DryRunOnFirstSync: true` | PBuO diff logA**g DB** | -| Dry-run Ҧ | `DryRun: true / false` | {vT DBAuX diff ]admin API iU^ | -| nR]¾^ | guardrail qL~ `status=suspended`]** deleted**^ | `deleted` ݤHuαM workflow | -| Sync window | `Window: 24h` | w]C 24hFi tenant override | -| iĵqD | `AlertSink: ops_webhook / mail` | IJo dry-run / ʲv / s򥢱Ѯɳq | - -> Worker ҰʶǡG LDAP snapshot p diff ] guardrail ˬd]threshold + ratio^ commit dry-run g audit logC - ---- - -## 11. Notification Module - -|G`internal/model/notification/` - -W model ҲաABzҦ **outbound qT**GEmailBSMSB]wd^PushBWebhookCҦ~ȼҲա]member ~ҡBauth step-upBtenant tγqBadmin ĵܵ^**Τ@**zL `NotifierUseCase` oeA**** import provider SDKC - -### 11.1 ¾d - -- Provider HGEmail / SMS / Push / Webhook iWߴ -- Template VGthyt]i18n^+ ܼƪ`J -- PBoePBƵ{]idempotency + + DLQ^ -- qGpersist Mongo]eFABprovider message idBretry y^ -- Rate limit / tB]zoBݥΡ^ -- HookG audit log P metrics dI - -### 11.2 Ҳ - -``` -member / auth / tenant / admin - x - (NotifierUseCase.Send / Enqueue) -notification ww repository (audit + outbox) - x - (interface) -internal/library/notification/ - uww email/ (sendgrid | ses | smtp @) - uww sms/ (twilio | sns | smsapi @) - |ww push/ (wd) -``` - -**library h**G IOAʸ˦Ua SDKF**model h**Gy{BҪOBretryBauditBidempotencyC - -### 11.3 - -```go -type NotifierUseCase interface { - // PBoeGoGP provider idFѦ^ error - Send(ctx context.Context, req *SendRequest) (*NotificationDTO, error) - // BƶGg Mongo outbox + J channelAworker ԨաF]R - Enqueue(ctx context.Context, req *SendRequest) (*NotificationDTO, error) - // d߳浧A - Get(ctx context.Context, tenantID, notificationID string) (*NotificationDTO, error) -} - -type SendRequest struct { - TenantID string - UID string // iš]tγq^ - Channel enum.Channel // email | sms | push | webhook - Kind enum.NotifyKind // verify_email | verify_phone | step_up | system_alert | ... - Target string // }]email / phone / device_token / url^ - Locale string // zh-tw | en-usAw tenant.default_locale - Data map[string]any // ҪOܼ - Severity enum.Severity // info | warn | critical - IdempotencyKey string // ~ keyFP key o - DoNotPersistBody bool // OTP ӷPeJwAuO metadata -} -``` - -> **OTP ӷPe**G`DoNotPersistBody=true` notification.body dšAuO channel/kind/target hash/provider_message_id/statusAקK audit DB X{X OTPC - -### 11.4 Entity P Collection - -```go -// notification collection -type Notification struct { - ID primitive.ObjectID - TenantID string - UID string - Channel enum.Channel - Kind enum.NotifyKind - TargetHash string // sha256(target)AקKX PII - TemplateKey string // TemplateRegistry - Locale string - Provider string // "sendgrid" | "twilio" | ... - ProviderMessageID string - Status enum.NotifyStatus // pending | sent | failed | retrying | dropped - Attempts int - LastError string - IdempotencyKey string // ߤ@ {tenant_id, kind, idempotency_key} - Severity enum.Severity - OccurredAt int64 - DeliveredAt int64 -} -``` - -**Template** **in-code registry**]Ow^+ provider ݼҪO ID]p SendGrid Dynamic Template^G - -```go -var TemplateRegistry = map[enum.NotifyKind]TemplateSpec{ - enum.NotifyVerifyEmail: { - EmailProviderTemplateID: "d-xxxxxxxxxxxxx", // SendGrid - SMSText: "", - RequiredVars: []string{"code", "expires_in"}, - }, - enum.NotifyVerifyPhone: { - SMSText: "Your verification code is {code} (valid {expires_in}s)", - RequiredVars: []string{"code", "expires_in"}, - }, - enum.NotifyStepUpEmail: {...}, - enum.NotifyStepUpPhone: {...}, - enum.NotifySystemAlert: {...}, -} -``` - -### 11.5 Idempotency P - -- `IdempotencyKey` ߤ@ޡG`{TenantID, Kind, IdempotencyKey}` -- Send P key ^WG]o provider^ -- B worker ѵGưh 1s / 5s / 30s / 5min / 30minA̦h 5 FWL `status=dropped` + audit -- DLQG 5 Odb `notification_dlq` collectionAadmin API i retry - -### 11.6 P~ȼҲժIsY - -| Is | Kind | Channel | Ҧ | -|--------|------|---------|------| -| `member.VerificationUseCase` | `verify_email` / `verify_phone` | email / sms | **PB**]nߧYDeF / ѡ^ | -| `member.StepUpUseCase` | `step_up_email` / `step_up_phone` | email / sms | **PB** | -| `member.AdminUseCase`]vi^ | `account_suspended` | email | **B** | -| `tenant.UseCase`]إߧ^ | `tenant_welcome` | email | **B** | -| ops alert]ʲv / DLQ ^ | `ops_alert` | email / webhook | **PB**]critical^ | - -> **OTP PB**A_h client Lk^uOTP wHXvT~FLquBקKC~ APIC - -### 11.7 P Audit Log Y - -C Notification gJɦPBg audit logG - -``` -action = notification.sent | notification.failed | notification.dropped -actor = system caller uid -target = { kind: notification, id: notification_id, channel, kind } -metadata = { provider, provider_message_id, target_hash } -``` - -audit log Ʀs body]wM 20.1 critical PBgd**t**qAȤƾڡ^C - -### 11.8 wP PII - -- `Target` persistFs `TargetHash`]sha256^AKhBidempotencyFXȦb send Uǵ provider -- Email/SMS provider API keyBTwilio token `etc/gateway.yaml` ܼ + secret manager -- Webhook qDj HTTPS + HMAC ñ]`X-CloudEP-Signature`^ - ---- - -## 12. iŪ UID ]p]wM^ - -### 12.1 榡 - -``` -{UIDPrefix}-{Sequence} - -dҡGAMEX-10000000BACME-10000001BACME-10000002 -``` - -**wMGae**]ί BodyB UUID^C - -| | Wh | d | -|------|------|------| -| `UIDPrefix` | 2~4 jgAӦ `tenant.UIDPrefix` slug Yg | `AMEX`B`ACME` | -| `Sequence` | Qi컼WơA**_l `10000000`**]u `InitAutoID` yN^ | `10000000` | -| j | Tw `-` | `AMEX-10000000` | - -- HiŪBȪAivrfz -- t UUID / base64 ýX -- **`UIDPrefix` xߤ@**]wM^FȪAJ UID Yiw tenant + member -- PᤣiۦP `UIDPrefix`FP prefix Sequence q `10000000` _ - -### 12.2 ͡]Bucket A䴩毲 50 U^ - -``` -Redis: member:seq:{tenant_id} counterAl 10000000 -C pod ҰʩίӺɮ INCRBY 500 @ bucketAbO餺 -UID = tenant.UIDPrefix + "-" + strconv.FormatInt(sequence, 10) -``` - -- **õoO@**G`{ tenant_id, uid }` unique indexC`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` / `CreateUnverified` R dup key]E11000^ fallback `GetByZitadelUserID` `GetByEmail` J memberC -- **Pod crash e**Gbucket Χᥢi]UID nDYsBnDY滼WFunD᤺ߤ@^C -- **UIDPrefix unique index**G`tenants.{ uid_prefix: 1 } unique`FدɭY prefix wsb 409C - ---- - -## 13. ƼҫP - -### 13.1 Collections - -| Collection | Ҳ | | -|------------|------|------| -| `members` | member | Profile]t~ҺXСBTOTP cipherBOrigin^ | -| `identities` | member | zitadel_sub ? uid | -| `tenants` | member | metadata | -| `tenant_ldap_configs` | member | LDAP PB]w][K^ | -| `permissions` | permission | Permission Tree]x seed^ | -| `roles` | permission | Role]`tenant_id` + immutable `key`^ | -| `role_permissions` | permission | Role ? Permission ID | -| `user_roles` | permission | uid ? Role]䴩h^ | -| `role_mappings` | permission | ~ Group ? RoleID / Role.Key | -| `notifications` | notification | qoe]idempotency / / audit^ | -| `notification_dlq` | notification | 5 Ѫ dead letter queue | -| `audit_logs` | ]W DB^| Ҳռfpx]TTL 90dA20.1^ | - -### 13.2 Dn - -```javascript -// members -{ tenant_id: 1, uid: 1 } // unique -{ tenant_id: 1, zitadel_user_id: 1 } // unique -{ tenant_id: 1, member_status: 1, create_at: -1 } - -// identities -{ tenant_id: 1, zitadel_user_id: 1 } // unique -{ tenant_id: 1, uid: 1 } -{ tenant_id: 1, external_id: 1 } - -// permissions]^ -{ name: 1 } // unique -{ parent: 1, status: 1 } -{ http_path: 1, http_method: 1 } // sparse - -// roles -{ tenant_id: 1, key: 1 } // unique -{ tenant_id: 1, status: 1 } - -// role_permissions -{ tenant_id: 1, role_id: 1, permission_id: 1 } // unique - -// user_roles -{ tenant_id: 1, uid: 1, role_id: 1 } // unique -{ tenant_id: 1, uid: 1 } - -// role_mappings -{ tenant_id: 1, external_source: 1, external_key: 1 } // unique -{ tenant_id: 1, internal_role_id: 1 } - -// notifications -{ tenant_id: 1, kind: 1, idempotency_key: 1 } // unique]P key o^ -{ tenant_id: 1, uid: 1, occurred_at: -1 } -{ status: 1, attempts: 1, occurred_at: 1 } // worker ݭ - -// notification_dlq -{ tenant_id: 1, occurred_at: -1 } - -// audit_logs]W DB / replica set^ -{ tenant_id: 1, occurred_at: -1 } -{ tenant_id: 1, "actor.uid": 1, occurred_at: -1 } -{ tenant_id: 1, action: 1, occurred_at: -1 } -{ occurred_at: 1 } // TTL 90d -``` - -> Identity MgH `identities` collection source of truthF`members.zitadel_user_id` YOdAu@Ϭd֨/denormalized AsݥѦP@ transaction θvy{@PC - -> **ɶ**G`CreateAt` / `UpdateAt` Τ@ **epoch milliseconds]UTC^**C~ SCIM `meta.created` / `meta.lastModified` SCIM mapper bǦCƮ RFC3339NanoFeݮiܥ client td timezoneC - -### 13.3 ]100 U+^ - -``` -Shard Key: { tenant_id: 1, uid: 1 } -``` - -毲 50 U|bP@ chunkAMongoDB iӨFYw毲dUŦA hash GC - ---- - -## 14. Redis Key RW - -### auth]`internal/model/auth/redis.go`^ - -``` -auth:jwt:bl:{jti} # token ¦WATTL = ѾlةR -auth:jwt:pair:{access_jti} # access_jti refresh_jti]nXɳs refresh @_Զ¡^ -auth:gen:{tenant_id}:{uid} # qĥN -auth:exchange:nonce:{id_token_jti} # Token Exchange ATTL 10min -auth:stepup:used:{jti} # Step-up token 榸ʡATTL = step_up_token TTL -``` - -### member]`internal/model/member/redis.go`^ - -``` -member:profile:{tenant_id}:{uid} # profile cacheATTL 5~15min -member:sub:{tenant_id}:{sub} # zitadel_sub uidATTL 1h -member:seq:{tenant_id} # UID bucket counter - -otp:challenge:{tenant_id}:{challenge_id} # {purpose, identifier, code_hash, attempts, expire_at}ATTL 5min -otp:rate:{tenant_id}:{purpose}:{identifier} # oNo 60s -otp:daily:{tenant_id}:{purpose}:{identifier} # W INCRATTL 24h - -totp:enroll:{tenant_id}:{uid} # enroll Ȧs secret_cipherATTL 10min -totp:used:{tenant_id}:{uid}:{timestep} # TOTP code ATTL 90s -``` - -### notification]`internal/model/notification/redis.go`^ - -``` -notif:idem:{tenant_id}:{kind}:{idempotency_key} # idempotency G֨ATTL 24h -notif:quota:{tenant_id}:{channel} # CCqD quotaAINCR + TTL -notif:retry:zset # BձƵ{]score = next_retry_at_ms^ -``` - -### permission]`internal/model/permission/redis.go`^ - -``` -permission:casbin:rules:{tenant_id} # Casbin policy rules]List of JSON^ -permission:tree:open # iGopen `I cache -perm:role_perms:{tenant_id}:{role_id} # role permission namesATTL 30min -perm:user_roles:{tenant_id}:{uid} # uid role keysATTL 5min -``` - ---- - -## 15. WһPʯ]100 U+ / 毲 50 U^ - -| | | -|------|------| -| Gateway | LAAXi | -| MongoDB | Sharding + Replica SetAŪ secondary | -| ListMembers | Cursor AT deep offset | -| Authorize | Casbin EnforceEx]?s + Redis policy^ | -| LoadPolicy | ??WqFcron 5min q© | -| JWT UID | Redis cache 1h | -| Directory Sync | 500 users / batchArate limit ZITADEL API | -| Access Token TTL | 15min]CMPf^ | - -### eqʦ - -``` -100 U members ~2KB ? 2GB]t index^ -indexes ? 1~2GB - 涰siӨAij 3 node replica set _ -``` - ---- - -## 16. ؿc - -``` -gateway/ -uww generate/api/ -x uww auth.api -x uww member.api -x uww permission.api -x uww tenant.api -x |ww scim.api -x -uww internal/ -x uww middleware/ -x x uww jwt_revoke.go -x x uww tenant_context.go -x x uww require_permission.go -x x |ww scim_auth.go -x x -x uww library/ -x x uww zitadel/ -x x x uww oidc.go -x x x |ww management.go -x x uww ldap/ -x x x uww client.go -x x x |ww attrmap.go -x x uww casbin/ # Enforcer l helper -x x uww uid/ -x x x uww encode.go -x x x |ww generator.go -x x uww totp/ # RFC 6238 tkBQR ͦ -x x x uww totp.go -x x x |ww backup_code.go -x x uww crypto/ # AES-GCM secret [ѱK + KMS -x x x |ww secret.go -x x |ww notification/ # Provider @] IO ʸˡ^ -x x uww email/ -x x x uww sendgrid.go -x x x uww ses.go -x x x |ww smtp.go -x x uww sms/ -x x x uww twilio.go -x x x uww sns.go -x x x |ww smsapi.go -x x |ww push/ # wd -x x -x uww model/ -x x uww auth/ -x x x |ww ... -x x uww member/ # t verification / step_up / totp usecase -x x x |ww ... -x x uww notification/ # Τ@qJf -x x x uww entity/ -x x x x |ww notification.go -x x x uww enum/ -x x x x uww channel.go -x x x x uww kind.go -x x x x |ww status.go -x x x uww repository/ -x x x x |ww notification.go -x x x uww usecase/ -x x x x uww notifier.go -x x x x uww template.go -x x x x |ww worker.go -x x x uww config/ -x x x uww errors.go -x x x |ww redis.go -x x |ww permission/ -x x uww entity/ -x x x uww permission.go -x x x uww role.go -x x x uww user_role.go -x x x uww role_permission.go -x x x |ww role_mapping.go -x x uww enum/ -x x x uww status.go -x x x |ww permission_type.go -x x uww repository/ -x x x uww permission.go -x x x uww role.go -x x x uww user_role.go -x x x uww role_permission.go -x x x uww role_mapping.go -x x x |ww casbin_redis_adapter.go # u permission-server -x x uww usecase/ -x x x uww permission_tree.go # u permission-server -x x x uww rbac.go # Casbin LoadPolicy / Check -x x x uww permission.go -x x x uww role.go -x x x uww role_permission.go -x x x uww user_role.go -x x x uww role_mapping.go -x x x |ww authorization_query.go -x x uww rbac/ -x x x |ww rule.go # Casbin Rule struct -x x uww config/ -x x uww errors.go -x x uww redis.go -x x |ww mock/ -x x -x |ww worker/ -x uww directory_sync/ -x uww policy_sync/ # iGw LoadPolicy -x uww notification_retry/ # BաBDLQ -x |ww member_anonymize/ # nR 30 ѫΦWơ]5.7^ -x -uww etc/ -x uww gateway.yaml -x |ww rbac.conf # Casbin ҫ]u permission-server^ -x -|ww docs/ - uww model.md - |ww identity-member-design.md # -``` - ---- - -## 17. ]w - -`etc/gateway.yaml` XRסG - -```yaml -Name: gateway -Host: 0.0.0.0 -Port: 8888 - -Auth: - AccessSecret: ${JWT_ACCESS_SECRET} - AccessExpire: 900 - -RefreshAuth: - AccessSecret: ${JWT_REFRESH_SECRET} - AccessExpire: 604800 - -Zitadel: - Issuer: https://id.internal.example.com # self-hosted - ClientID: ${ZITADEL_CLIENT_ID} - JWKSUrl: https://id.internal.example.com/oauth/v2/keys - MgmtURL: https://id.internal.example.com/management/v1 - MgmtToken: ${ZITADEL_MGMT_TOKEN} - EnforceAdminMFA: true # admin role]tenant_owner/tenant_admin/platform_super_admin^j TOTP - # Self-hostedGLDAP IdP ZITADEL s~ AD/OpenLDAP - -StepUp: - TokenSecret: ${JWT_STEPUP_SECRET} - TokenTTLSeconds: 300 - AllowedActions: - - change_business_email - - change_business_phone - - delete_member - - tenant_admin_force_status - - revoke_all_sessions - -Verification: - OTPLength: 6 - OTPTTLSeconds: 300 - ResendCooldownSeconds: 60 - DailyLimit: 10 - MaxAttempts: 5 - -TOTP: - Issuer: CloudEP # ܦb Authenticator App WW - Algorithm: SHA1 # ۮe Google Authenticator - Digits: 6 - PeriodSeconds: 30 - Window: 1 # e 1 30s ϶ - BackupCodeCount: 10 - BackupCodeLength: 12 # hex chars - SecretKEK: ${TOTP_KEK} # AES-256 KEKFij KMS / Vault - EnrollTTLSeconds: 600 - -Notification: - DefaultLocale: zh-tw - Async: - QueueRedisKey: notif:retry:zset - Worker: 4 # worker goroutine - MaxRetry: 5 - BackoffSeconds: [1, 5, 30, 300, 1800] - RatePerTenant: # CqDtB]zo / ݥΡ^ - Email: 10000 # C - SMS: 5000 - Email: - Provider: sendgrid # sendgrid | ses | smtp - APIKey: ${SENDGRID_API_KEY} - From: noreply@example.com - Templates: # TemplateRegistry key provider template id - verify_email: d-xxxxxxxxxxxxx - step_up_email: d-yyyyyyyyyyyyy - account_suspended: d-zzzzzzzzzzzzz - tenant_welcome: d-aaaaaaaaaaaaa - SMS: - Provider: twilio # twilio | sns | smsapi - AccountSID: ${TWILIO_ACCOUNT_SID} - AuthToken: ${TWILIO_AUTH_TOKEN} - From: "+1234567890" - Templates: - verify_phone: "Your verification code is {code} (valid {expires_in}s)" - step_up_phone: "Step-up code: {code}" - Push: - Enabled: false # wd - Webhook: - HMACSecret: ${NOTIF_WEBHOOK_HMAC} - -Mongo: - # internal/library/mongo ]w - -Redis: - Host: 127.0.0.1:6379 - Type: node - -Member: - DefaultLanguage: zh-tw - DefaultCurrency: TWD - -Permission: - RBACModelPath: etc/rbac.conf - PolicySyncInterval: 5m - PolicyReloadChannel: casbin:reload # Redis Pub/Sub qD]YɳqA5m cron ©^ - PlatformAdminTenantID: ${PLATFORM_ADMIN_TENANT_ID} - PlatformAdminRoleKey: platform_super_admin - PlatformAdminAllowlistUIDs: ${PLATFORM_ADMIN_ALLOWLIST_UIDS} # break-glass ΡA audit - CacheTTLSeconds: 300 - -DirectorySync: - MissingThreshold: 3 - MaxChangeRatio: 0.20 - DryRunOnFirstSync: true - DefaultWindow: 24h - AlertSink: ${OPS_WEBHOOK_URL} - -AuditLog: - Sink: mongo # mongo | otel | dual - Mongo: - DB: gateway_audit # ijW DB instance / replica set - Collection: audit_logs - BatchSize: 100 - FlushInterval: 1s - TTLDays: 90 - OTEL: - Endpoint: ${OTEL_ENDPOINT} # Sink = otel / dual ɥͮ - -RateLimit: - Enabled: true - RedisPrefix: rl - WindowSeconds: 60 - Rules: - - Match: /api/v1/auth/* - ByIP: 60 # 60 req / min / IP - ByUID: 30 # 30 req / min / UID]wnJɡ^ - - Match: /api/v1/auth/step-up/* - ByUID: 10 - - Match: /scim/v2/* - ByToken: 6000 # 6000 req / min / SCIM token] 100rps^ - - Match: /api/v1/* - ByUID: 600 # @ API W - ByIP: 1200 -``` - ---- - -## 18. I - -| q | e | X | -|------|------|------| -| **P0** | ؿ[BentityBredis keyBconfigB**`make seed-platform-admin` CLI**]ح platform admin uid + role^ | iҰʡBis Mongo/RedisAx admin inJ | -| **P1** | UID generator + ProvisioningUseCase]OIDC/LDAP/SCIM T^+ token exchange | inJo JWT + iŪ UID | -| **P2** | JWT middleware + jti ¦W + auth_gen + logout/refresh | Token ͩRg | -| **P3** | Permission seed + PermissionTree + Casbin RBAC + Redis Adapter | i LoadPolicy / Check | -| **P3.5** | Notification Module]Τ@Jf + Email/SMS Provider^+ Verification + Step-up MFA + **TOTP** | ~ + TOTP step-up + Iu | -| **P4** | member profile API + w] Role seed + CasbinRBACMiddleware | `/members/me` + API vͮ | -| **P5** | RolePermission + UserRole + B2B Role CRUD + Permission Ŀ API | ᧹۩wq | -| **P6** | Tenant إ + ZITADEL CreateOrg + LDAP ]w | h | -| **P7** | Directory Sync Worker]AD + OpenLDAP^+ 10.4 guardrail | ~ؿPB]~PO@ơ^ | -| **P8** | SCIM 2.0 endpoint + Group Mg | ~ provisioning | -| **P8.5** | Audit log sink]Mongo W collection^+ Rate Limit middleware] 20^ | ifp / ݥ | -| **P9** | ]100 U seed^BshardingBuBJWT kid h | Wudz | - ---- - -## 19. wMƶ - -| # | ijD | **M** | ]pvT | -|---|------|----------|----------| -| 1 | UID 榡 | **`{Prefix}-{Sequence}`**Ap `AMEX-10000000` | 12FSequence _ `10000000` | -| 2 | SCIM | **`/scim/v2/tenants/{tenant_id}/...`** | 7.5B10.3 | -| 3 | ZITADEL p | **Self-hosted** | 3.3FLDAP /VPN su | -| 4 | vܧͮ | **UserRole ܧ `INCR auth_gen`FRolePermission ܧ reload policy + cache invalidate** | 4.5B6.11 | -| 5 | B2C | **Ū seed ҪO**Ai۩wq Role | 6.12FB2C T Role CRUD API | -| 6 | Refresh Token | ** + refresh jti ¦W** | 4.5 Refresh | -| 7 | Casbin hj | **policy a `tenant_id` + immutable `role_key`** | 6.7FקKPW role 󯲤ìV | -| 8 | SCIM externalId | **OdȤݥ~ѧOA Gateway UID** | 10.3FGateway UID @ SCIM id extension | -| 9 | Platform Admin bypass | **x role + allowlistA audit** | 6.7B8.2Fb Casbin matcher | -| 10 | UIDPrefix | **xߤ@**]`tenants.uid_prefix` unique index^ | 12.2 | -| 11 | JWT Claims e | ** role / permission ַ**ACd cache | 4.3 | -| 12 | Refresh Token Reuse | ** refresh Gϥ = s INCR auth_gen + audit** | 4.5 | -| 13 | Token Exchange | **id_token nonce SETNX + iat 5 f** | 4.5 | -| 14 | Logout | **Issue redis O access?refresh jti pair** | 4.5 | -| 15 | RolePermission API yN | **PUT qN** `{ permission_names: [...] }` + ja tenant_id | 6.8B7.3B9.3 | -| 16 | ~ӷ UserRole | ** source j Replace**Amanual äQ~ | 6.10 | -| 17 | PlainCode @ | **Casbin B~d `.plain_code` **Ah role allow G OR | 6.9 | -| 18 | Permission.Name | **إ߫ᤣiW**Fo `status=close` + s | 6.4 | -| 19 | U| | **B2C**GGateway Τ@ `/auth/register*`]Email + SocialAinvite ^F**B2B**GLDAP / SCIM g register APIFplatform-native usecase wΩ Email U | 3.4B[auth-unified-registration.md](./auth-unified-registration.md) | -| 20 | vs ~Ҥh | **ZITADEL ޵nJFGateway member ~ email / phone** | 1.2B5.4 | -| 21 | Step-up MFA | **ҥ**FI action 5min 榸 `step_up_token` | 5.6B9.6 | -| 22 | OTP 뻼qD | **۰e**]zL Notification Module ] Email / SMS Provider^ | 5.5B11B17 | -| 23 | MFA j | **xj admin role ZITADEL TOTP**F@ user w]jAI Step-up | 3.5 | -| 24 | KYC | **b쪩d** | X | -| 25 | ~ TOTP]Authenticator App^ | **ҥ**AGateway ۦs AES-GCM [K secretFP ZITADEL TOTP W | 5.8 | -| 26 | Step-up qDu | **TOTP > SMS > Email**FStart ɨ enrolled ADqDAi client `prefer_channel` мg | 5.6 | -| 27 | Notification Module | W model Ҳ `internal/model/notification/`A**Ҧ outbound qT**Τ@ `NotifierUseCase`Flibrary h provider IO ʸ | 11 | -| 28 | OTP Jw | OTP / step-up ӷPe `DoNotPersistBody=true`Anotification ȯd metadata]target_hashBprovider_message_idBstatus^ | 11.3B11.8 | -| 29 | UseCase h | **Atomic primitives + Composite** hGlʧ@]Profile / Lifecycle / Provisioning / OTP / TOTP^iNզXFComposite]Verification / StepUp^`βզXwʸˡFlogic iܸ| | 5.2 | -| 30 | OTP ]p | **Purpose-agnostic atomic primitive**G`OTPUseCase.Generate / Verify / Invalidate`F`purpose` ѥγ~]registration_email / business_email / step_up / ...^Acaller ۭt뻼PƧ@ | 5.2.1B5.2.4B5.9 | -| 31 | Provisioning | `EnsureMember`  **`EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM`** T atomicFPӷ޿褬X | 5.2.1 | -| 32 | xUA | [^ `unverified` AA**** platform-native ||X{FOIDC / LDAP / SCIM `active` | 5.3 | -| A | SCIM `id` | **SCIM `id` = Gateway UID**]HŪBtΤ@P^F`externalId` dȤݡFZITADEL `sub` extension `urn:cloudep:scim:2.0:User:zitadelSub` | 10.3 | -| B | Casbin h pod PB | **Redis Pub/Sub Yɳq + 5min cron q reload ©**]OIApod Ҥ|^ | 6.11 | -| C | Tenant إ߶ | **Gateway tenant Z]`status=provisioning`^ Is ZITADEL Mgmt Org ^ `org_id` `status=active`FѨv cron թΤHu failed** | 3.1B7.4 | -| D | Platform Admin Bootstrap | **`make seed-platform-admin` CLI**]ح platform admin uid + role^DF`PLATFORM_ADMIN_ALLOWLIST_UIDS` ܼƧ@ break-glassA**j audit** | 18 P0 | -| E | Hybrid y | **æs**G`Member.Origin`]DӷGzitadel_local / ldap / scim^+ `UserRole.Source`]C role ӷ^Fsync replace sourceBŪ origin | 3.2B5B6.10 | -| F | SCIM endpoint v | **쪩 tenant SCIM Token v**]read+write^+ IP allowlist + rate limit + token rotationFv2 A[ `scim.users.write` / `scim.groups.write` scope | 7.5 | -| G | Audit log sink | **W Mongo `audit_logs` collection**]ijW DB instance οW replica set^+ TTL 90 + B batch flushFIƥPBgFi OTEL log gk | 4.5B8.2B20 | -| H | bR | **nR 30 ѫΦW**GߧY `status=deleted` + MP token + ZITADEL disableF30 cron ΦW PII ]email/phone/displayName/avatar/zitadel_sub/business_*^FOd uid/tenant_id/timestamps/audit s | 5.3B5.7 | -| I | Member SoT | **쵦**G]zitadel_subBIdP email/nameBZITADEL status^ ZITADEL ǡF~]business_email/phoneBlanguageBcurrencyBavatar^ Gateway ǡFprovisioning ]external_idBldap_dn^ ӷtά | 5B9.1 | -| J | Directory Sync ~PO@ | **s 3 ]s 3 ѡ^䤣~ suspend**B榸 sync > 20% ۰ dry-run + iĵBpj dry-runBR cron qL guardrail | 10.4 | -| K | Rate Limiting | **go-zero middleware + Redis sliding-window h**GIP / UID / TenantSCIMToken ThF`/auth/*` C IP 60rpm + C UID 30rpmF`/scim/*` C token 100rpsF@ API C UID 600rpmFOTP 5.5 JNo | 17 RateLimitB20 | -| L | JWT Secret Rotation | **䴩 `kid` header + h key æs**GAccess / Refresh / Step-up UۿW key setFñoγ̷s kidAҨ active kid WFy{Gos kid s token ηs kid token expire kid | 4.4 | - ---- - -## 20. Audit Log P Rate Limit - -### 20.1 Audit Log - -**Sink]wM^**GW Mongo `audit_logs` collection]ij**W DB instance** replica setAקK OLTP ^C - -```go -type AuditLog struct { - ID primitive.ObjectID - TenantID string - Action string // member.created | role.assigned | step_up.confirmed ... - Actor Actor // {uid, role_keys, ip, ua, jti} - Target Target // {kind: member|role|tenant, id, before, after} - Severity enum.Severity // info | warn | critical - Result enum.Result // success | denied | error - Reason string // ѭ] / denied z - Metadata bson.M // ʺAAp step_up_jtiBscim_opBsource - OccurredAt int64 // epoch ms -} -``` - -**gJG** - -| Severity | Ҧ | ѳBz | -|----------|------|---------| -| `critical`]vBRBstep-upBPlatform Admin bypassBvMP^ | **PB**gJFgѫhӷ~Ⱦާ@^u | ڵШDAקKLqL | -| `info`]ŪBvqL^ | **B**Gbuffered channel batch insert]`BatchSize=100`B`FlushInterval=1s`^ | drop + metrics]iĵAvT~ȡ^ | - -- TTL indexG`{ OccurredAt: 1 }` TTL 90 ѡFWLhkɡ]i OTEL log gOd[^ -- IndexG`{ TenantID: 1, OccurredAt: -1 }`B`{ TenantID: 1, Actor.uid: 1, OccurredAt: -1 }`B`{ TenantID: 1, Action: 1, OccurredAt: -1 }` -- **ΦWƤvT audit**Gactor / target uid Od]Y member wΦWơ^AFṳ֥n PII + sʡv - -### 20.2 Rate Limit - -**޳N﫬]wM^**Ggo-zero middleware]ۻs / l͡^+ Redis sliding-windowC - -``` -Key: rl:{dimension}:{key}:{path_pattern} # dimension = ip | uid | scim_token -Value: ZSET]timestamp_ms : nonce^TTL = WindowSeconds -``` - -**tk**G - -``` -1. now := time.Now().UnixMilli() -2. ZREMRANGEBYSCORE rl:... 0 (now - window_ms) -3. count := ZCARD rl:... -4. if count >= limit 429 + Retry-After -5. ZADD rl:... now {random} -6. EXPIRE rl:... window -``` - -**hRWh]Ǥǰt^G** - -| | | | W | -|------|------|------| -| `/api/v1/auth/step-up/*` | UID | 10 req/min | -| `/api/v1/auth/*` | IP / UID | 60 / 30 req/min | -| `/scim/v2/*` | SCIM token | 6000 req/min] 100rps^ | -| `/api/v1/*`]l^ | UID / IP | 600 / 1200 req/min | - -- **} endpoint**]exchange / refresh^H IP DBUID ]nJɵL UID^ -- R^ `429` + `Retry-After: {seconds}` + `X-RateLimit-Remaining` -- OTP / ~Ҩ 5.5 `verify:rate` / `verify:daily`A****g RateLimit middleware]קKNoQӡ^ -- ]w 17 `RateLimit` - ---- - -## AGP model.md Y - -- G****][cBy{BAPIBvҫ^ -- [model.md](./model.md)G**g**]entity / repository / usecase {XWd^ - -@ɨftϥΡC - ---- - -## BGServiceContext ո˯ - -```go -type ServiceContext struct { - Config config.Config - Validator validate.Validate - - // library clients] IOAºʸ˥~ SDK^ - Zitadel *zitadel.Client - EmailSender libemail.Sender - SMSSender libsms.Sender - SecretCipher libcrypto.Cipher // TOTP secret [ѱK - TOTPGen libtotp.Generator - - // usecases - AuthUC authusecase.TokenUseCase - StepUpTokenUC authusecase.StepUpTokenUseCase - MemberProvUC memberusecase.ProvisioningUseCase - MemberProfileUC memberusecase.ProfileUseCase - MemberAdminUC memberusecase.AdminUseCase - VerificationUC memberusecase.VerificationUseCase - StepUpUC memberusecase.StepUpUseCase - TOTPUC memberusecase.TOTPUseCase - TenantUC memberusecase.TenantUseCase - ScimUC memberusecase.ScimUseCase - - // notification module - NotifierUC notifusecase.NotifierUseCase - - // permission usecases] permission-server ^ - PermRBACUC permusecase.RBACUseCase - PermUC permusecase.PermissionUseCase - RoleUC permusecase.RoleUseCase - RolePermUC permusecase.RolePermissionUseCase - UserRoleUC permusecase.UserRoleUseCase - RoleMappingUC permusecase.RoleMappingUseCase - AuthQueryUC permusecase.AuthorizationQueryUseCase -} -``` - ---- - -## CGpermission-server Eӡ]{Xš^ - - - -| permission-server ɮ | Gateway ؼ | E覡 | -|------------------------|--------------|----------| -| `pkg/usecase/permission_tree.go` | `model/permission/usecase/permission_tree.go` | XG˷h | -| `pkg/usecase/casbin_redis_rbac.go` | `model/permission/usecase/rbac.go` | [ `tenant_id` + `role_key` | -| `pkg/repository/casbin_redis_adapter.go` | `model/permission/repository/casbin_redis_adapter.go` | אּ tenant-scoped policy key | -| `pkg/domain/rbac/rule.go` | `model/permission/rbac/rule.go` | ˷h | -| `etc/rbac.conf` | `etc/rbac.conf` | [J tenant request / policy | -| `pkg/usecase/role.go` | `model/permission/usecase/role.go` | `ClientID``TenantID` | -| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | [ `tenant_id` bPdߺ | -| `pkg/usecase/user_role.go` | `model/permission/usecase/user_role.go` | 䴩h | -| `pkg/usecase/token.go` | **`model/auth/usecase/token.go`** | b permission Ҳ | -| `generate/database/seeders/*_permission*` | `generate/database/seeders/` Mongo seed | אּ Gateway seed job | - ---- - -## ׭q - -| | | | -|------|------|------| -| 2026-05-19 | 0.1.0 | ZGauth + member + permission]B2B ۩wq^+ ZITADEL/LDAP/SCIM | -| 2026-05-19 | 0.2.0 | app-cloudep-permission-serverGCasbin RBACBPermission TreeBRole/RolePermission | -| 2026-05-19 | 0.3.0 | ww 19]1V6^GUID e榡BSCIM tenant_id ѡBZITADEL self-hostedBauth_gen jsBB2C ŪBRefresh | -| 2026-05-19 | 0.4.0 | ɱjh CasbinBimmutable Role.KeyBSCIM externalIdBPlatform Admin bypass Pvͮĵ | -| 2026-05-20 | 0.5.0 | Best-practice ġGJWT role ַӡBRefresh Reuse DetectionBToken Exchange NonceBLogout pairBRolePermission tenant b + PUT qNB~ӷ source jBPlainCode EXBPermission.Name iBUIDPrefix xߤ@BRole.Key WhBƬ ABC | -| 2026-05-20 | 0.6.0 | ɤJ~ҤhGGateway ѵU API]3.4^FsW~ Email / Phone ]5.4B9.5^FStep-up MFA ҥΡ]5.6B9.6^FOTP ۰e Email + SMS Provider]5.5B17 Notification^Fx admin j ZITADEL TOTP]3.5^FsW Redis keyBAPIB]wBMC 19V24 | -| 2026-05-20 | 0.7.0 | ݨM AVL ƩOGSCIM id = Gateway UID + ZITADEL sub extension]10.3^FCasbin h pod Pub/Sub + 5min cron ©]6.11^FTenant إ saga]3.1^FPlatform Admin seed CLI]18 P0^FMember.Origin + UserRole.Source ]5.4B6.10^FSCIM token v + IP allowlist]7.5^FW audit_logs collection + TTL 90d]20.1^FnR 30 ѰΦWơ]5.7^F SoT]5.3^FDirectory Sync guardrail]10.4^FRedis sliding-window rate limit]20.2^FJWT kid h key æs]4.4^ | -| 2026-05-20 | 0.8.0 | XW **Notification Module**]11^GҦ outbound qTΤ@JfBt idempotency / / DLQ / ҪO / hyBӷPe `DoNotPersistBody`FsW **~ TOTP**]5.8^䴩 Google AuthenticatorAP ZITADEL TOTP WߡFstep-up qDuǧאּ **TOTP > SMS > Email**]5.6^FؿBServiceContextBMongo collectionsBRedis keyB]wɡBIǡBMC 25V28 PBsF11V19 `s +1 | -| 2026-05-20 | 0.9.0 | **UseCase ᵲ]~޿Ȥ@^**G5.2 g Atomic primitives + Composite hFsW `OTPUseCase`]purpose-agnostic atomic^B`LifecycleUseCase`]CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending^F`ProvisioningUseCase` `EnsureFromOIDC / LDAP / SCIM` TF`ProfileUseCase` [ `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomicF[^ `unverified` A] platform-native |^Fɧ Member entity BEnum סBRequest DTOFsW 5.9 sƥܨҡ]5 case^F14 OTP Redis key purpose-basedFMC 19 ץBsW 29V32 | -| 2026-05-21 | 1.0.0 | **Gateway Τ@Uw@**G׭q 3.4]אּS `/auth/register*`^F7.1 ɻw@ auth ѡF8.1 O CloudEP JWT + dev header fallbackF9.1 login / token exchangeF5.9 Case A Ьw@FMC 19 sCԨ [auth-unified-registration.md](./auth-unified-registration.md) | +| auth | ? w@ | Τ@U (Email + Google) / login / token refresh / logout | +| member | ? w@ | profile / ~ email/phone OTP / TOTP y{ | +| permission | ? w@ | Catalog / Role CRUD / RolePermission / UserRole / RoleMapping / Casbin RBAC | +| notification | ? ֤ߧ | Email/SMS sync+async + DLQFHTTP admin API | +| LDAP Directory Sync | ? @ | SCIM / SyncFromX i | +| SCIM 2.0 server | ? @ | ̥~ȤݨD | +| Audit Log | ? @ | W collection + TTL 90d | +| HTTP Rate Limit middleware | ? @ | OTP cooldown wFݸ | diff --git a/etc/rbac.conf b/etc/rbac.conf index c7e0e29..ba6ba55 100644 --- a/etc/rbac.conf +++ b/etc/rbac.conf @@ -10,7 +10,8 @@ # Matcher: same tenant + same role + path keyMatch2 + method regexMatch # # 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] r = tenant, role, path, method diff --git a/internal/model/auth/README.md b/internal/model/auth/README.md new file mode 100644 index 0000000..af17460 --- /dev/null +++ b/internal/model/auth/README.md @@ -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 模組 diff --git a/internal/model/member/README.md b/internal/model/member/README.md index 3599d1c..cbcc220 100644 --- a/internal/model/member/README.md +++ b/internal/model/member/README.md @@ -1,54 +1,37 @@ # 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): -> usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。 -> 本 module 所有 usecase 都是 **atomic primitives**。 +> **架構原則**([`docs/model.md`](../../../docs/model.md) §6.1):usecase **不可** 呼叫其他 usecase;多步流程(OTP → 寄信 → 驗碼 → flip flag)一律在 `internal/logic/member/` 編排。 +> +> **規格 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` | -| **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` | +| **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` | -**Member 雙鍵**:`(tenant_id, uid)` 為對外的可讀主鍵;`zitadel_user_id` 是 OIDC 來源的對映鍵。 -**多租戶等級**:每個 Member 必屬於一個 Tenant,UID 用 `{TenantUIDPrefix}-{Sequence}` 格式(例:`ACME-10000003`)。 +對外可讀主鍵:`(tenant_id, uid)`;UID 格式 `{UIDPrefix}-{Sequence}`(例:`ACME-10000003`)。 -### 來源(Origin) +**Origin:** `platform_native`(前台註冊)/ `oidc`(ZITADEL/Social)/ `ldap` / `scim` -``` -platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified) -oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC) -ldap // Directory Sync(Provisioning.EnsureFromLDAP) -scim // SCIM 2.0(Provisioning.EnsureFromSCIM) +**狀態機:** + +```mermaid +stateDiagram-v2 + [*] --> 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/ +├── README.md ├── config/ # OTP / TOTP / Registration 設定 -├── domain/ # 介面、enum、entity、errors、redis key helper -│ ├── const.go # BSON 欄位、UID 常數 -│ ├── entity/ # Member、Tenant、Identity Mongo doc -│ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind -│ ├── errors.go # ErrNotFound、ErrDuplicateMember 等 -│ ├── redis.go # GetOTPChallengeRedisKey 等 helper -│ ├── repository/ # 7 個 repository 介面 -│ └── usecase/ # 7 個 usecase 介面 + DTO -├── repository/ # Mongo / Redis 實作 -├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL) -├── usecase/ # 7 個 usecase 實作 + module factory + mapper -└── README.md # 本檔 +├── domain/ # 介面、enum、entity、errors、redis key +│ ├── entity/ # Member / Tenant / Identity +│ ├── enum/ # MemberStatus / Origin / OTPPurpose / TenantStatus / VerifyKind +│ ├── repository/ # 7 個 repository 介面 +│ ├── usecase/ # 7 個 usecase 介面 + DTO +│ ├── const.go # BSON 欄位、UID 常數 +│ ├── errors.go # ErrNotFound / ErrDuplicateMember ... +│ └── redis.go # GetOTPChallengeRedisKey ... +├── 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 層
(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 一覽 | UseCase | 介面方法 | 職責 | -| --- | --- | --- | -| **TenantUseCase** | `Create` / `ResolveBySlug` | 建立租戶、依 slug 反查 | -| **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換(嚴格的 from→to 檢查) | -| **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | 讀取 / patch 可變欄位、業務 contact 標記已驗證 | -| **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登/同步 upsert(Member + Identity) | -| **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 產出/驗證一次性數字碼(bcrypt + Redis) | -| **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) | -| **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | OTP 重發冷卻 + 每日上限 | +|---------|---------|------| +| **TenantUseCase** | `Create` / `ResolveBySlug` | 建租戶、依 slug 反查 | +| **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換 | +| **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | profile 讀寫、業務 contact 驗證標記 | +| **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登 upsert(冪等) | +| **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 一次性數字碼(bcrypt + Redis) | +| **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) | +| **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 -| Collection | Entity | 主要索引 | -| --- | --- | --- | -| `members` | `Member` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)`(sparse) | -| `tenants` | `Tenant` | unique `slug`、unique `uid_prefix` | -| `identities` | `Identity` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` | +| Collection | 主要索引 | +|------------|---------| +| `members` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)` sparse | +| `tenants` | unique `slug`、unique `uid_prefix` | +| `identities` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` | -索引建立由 `repository.EnsureMongoIndexes` 在啟動時執行(對應 `cmd/mongo-index`)。 +索引建立由 `repository.EnsureMongoIndexes`(`cmd/mongo-index` 會跑)。 ### Redis Keys -| Key 前綴 | 用途 | TTL | -| --- | --- | --- | -| `member:otp:challenge:{id}` | OTP challenge 主紀錄(bcrypt hash) | `OTP.TTLSeconds`(預設 300) | -| `member:otp:challenge:{id}:attempts` | OTP 錯誤次數計數 | 同 challenge | -| `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 lock | `OTP.ResendCooldownSeconds`(預設 60) | -| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限計數 | 24h | -| `member:totp:enroll:{tenant}:{uid}` | 綁定中的 staged secret(AES-GCM cipher) | `TOTP.EnrollTTLSeconds`(預設 600) | -| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | `TOTP.ReplayTTLSeconds`(預設 90) | -| `member:seq:{tenant}` | UID 序號(`INCR`) | 永久 | +| Key | 用途 | TTL | +|------|------|-----| +| `member:otp:challenge:{id}` | OTP challenge(bcrypt hash) | `OTP.TTLSeconds`(預設 300) | +| `member:otp:challenge:{id}:attempts` | OTP 錯誤次數 | 同 challenge | +| `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 | `OTP.ResendCooldownSeconds`(預設 60) | +| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 | 24h | +| `member:totp:enroll:{tenant}:{uid}` | 綁定中 staged secret cipher | `TOTP.EnrollTTLSeconds`(預設 600) | +| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP replay 保護 | `TOTP.ReplayTTLSeconds`(預設 90) | +| `member:seq:{tenant}` | UID 序號 | 永久 | -Helper 函式見 `domain/redis.go`,**禁止** 在他處字串拼接 key。 +Key helper 在 `domain/redis.go`,**禁止** 在他處字串拼接。 --- -## 生命週期與狀態機 +## 重要流程 -```mermaid -stateDiagram-v2 - [*] --> unverified: Lifecycle.CreateUnverified
(platform 註冊) - [*] --> active: Provisioning.Ensure*
(OIDC/LDAP/SCIM 首登) +### 1. 業務 Email / Phone OTP 驗證(logic 編排示範) - unverified --> active: Activate
(OTP 驗證通過) - unverified --> deleted: AbortPending
(註冊逾時) - - 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 串起來。 +由 `internal/logic/member/verify_helper.go` 串多個 atomic: ```mermaid sequenceDiagram autonumber participant Client participant Logic as logic/member.startVerification - participant Rate as VerifyRateUseCase - participant OTP as OTPUseCase + participant Rate as VerifyRate + participant OTP participant Notif as Notifier - participant Profile as ProfileUseCase - participant Redis + participant Profile Client->>Logic: POST /me/verifications/email/start {target} - Logic->>Rate: AssertResendAllowed(rateKey, cooldown=60s) - Rate->>Redis: SETNX member:verify:rate:{t}:{u}:business_email - alt cooldown 中 - 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) + Logic->>Rate: AssertResendAllowed(cooldown) + Logic->>Rate: AssertDailyAllowed(每日上限) + Logic->>OTP: Generate(purpose=BusinessEmail, target=email) OTP-->>Logic: challenge_id, plainCode - Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in}) + Logic->>Notif: Send(VerifyEmail, code) alt Notifier 失敗 - Logic->>OTP: Invalidate(challenge_id) - Logic-->>Client: 5xx - else 成功 - Logic-->>Client: {challenge_id, expires_in} + Logic->>OTP: Invalidate(challenge_id) end + Logic-->>Client: {challenge_id, expires_in} Note over Client,Profile: 使用者收到信 Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code} - Logic->>OTP: Verify(challenge_id, code, uid, purpose=BusinessEmail) - 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) - Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target) - Profile-->>Logic: nil - Logic-->>Client: 204 - end + Logic->>OTP: Verify (bcrypt compare、attempts ↑↑↑) + OTP-->>Logic: target(email) + Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target) + Logic-->>Client: 204 ``` -**關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門。 +Key:`Verify` 成功後 challenge 立刻刪除(一次性);`Generate` 必先過 `VerifyRate` 兩道閘。 -### 6. TOTP 綁定 / Step-up +### 2. TOTP(綁定 + step-up + 重放保護) ```mermaid sequenceDiagram autonumber participant Client - participant Logic participant TOTP as TOTPUseCase participant Profile as TOTPProfileRepository participant Enroll as TOTPEnrollStore participant Replay as TOTPReplayStore - participant Cipher as crypto.Cipher (AES-GCM) + participant Cipher as crypto.Cipher - Note over Client,Cipher: A. 綁定階段 - Client->>Logic: POST /me/totp/enroll - Logic->>TOTP: StartEnroll(tenant, uid, account) - TOTP->>Profile: Get → 必須未 enrolled - TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte) - TOTP->>Cipher: Encrypt(secret) → cipherBlob + Note over Client,Cipher: 綁定階段 + Client->>TOTP: StartEnroll(tenant, uid, account) + TOTP->>Profile: 必須未 enrolled + TOTP->>Cipher: Encrypt(secret) TOTP->>Enroll: Save(cipherBlob, TTL=600s) - TOTP-->>Logic: {otpauth_url, digits=6, period=30} - Logic-->>Client: QR code 資料 + TOTP-->>Client: {otpauth_url, digits, period} - Client->>Client: 掃 QR 加入 Authenticator - Client->>Logic: POST /me/totp/enroll/confirm {code} - Logic->>TOTP: ConfirmEnroll(tenant, uid, code) - TOTP->>Enroll: Get → cipherBlob + Client->>TOTP: ConfirmEnroll(code) + TOTP->>Enroll: Get cipherBlob TOTP->>Cipher: Decrypt → secret - TOTP->>TOTP: totp.Verify(secret, code, ±window) - alt 驗碼失敗 - TOTP-->>Logic: ErrTOTPInvalidCode - else 成功 - TOTP->>TOTP: 產生 N 個 backup codes + bcrypt hashes - TOTP->>Profile: Save({Enrolled, SecretCipher, BackupCodesHash}) - TOTP->>Enroll: Delete (清掉 staged) - TOTP-->>Logic: plainCodes[](僅此一次回傳) - end + TOTP->>TOTP: totp.Verify(±window) + TOTP->>Profile: Save (Enrolled, SecretCipher, BackupCodesHash) + TOTP-->>Client: plainBackupCodes(僅此一次回傳) - Note over Client,Replay: B. 日常 step-up - Client->>Logic: 任意敏感操作攜 6 碼 - Logic->>TOTP: VerifyCode(tenant, uid, code) - 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 已用過 - 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 + Note over Client,Replay: 日常 step-up + Client->>TOTP: VerifyCode(code) + TOTP->>Replay: MarkUsed(timestep) → fresh? + alt 已用過 + TOTP-->>Client: ErrTOTPCodeReplay end ``` -### 7. UID 生成 +### 3. UID 生成 ```mermaid sequenceDiagram - autonumber - participant Caller as Lifecycle / Provisioning - participant Gen as UIDGenerator - participant Redis - Caller->>Gen: Next(tenant, uidPrefix) Gen->>Redis: INCR member:seq:{tenant} - Redis-->>Gen: seq - alt seq == 1 (首次) - Note right of Gen: 一次補上起始值
(避開像 ACME-1 這種短 UID) - Gen->>Redis: INCRBY (UIDSequenceStart - 1) = 9_999_999 - Redis-->>Gen: 10_000_000 + alt seq == 1(首次) + Gen->>Redis: INCRBY 9_999_999 → 10_000_000 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 命名 - -| 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` 區塊: +## 設定(`etc/gateway.dev.yaml`) ```yaml Member: Registration: - RequireInviteCode: true # 平台註冊是否強制邀請碼 - TrustSocialEmailVerified: true # OIDC email_verified=true 時直接 active + RequireInviteCode: true + TrustSocialEmailVerified: true # OIDC email_verified=true 直接 active OTP: - Length: 6 # 驗證碼位數 - TTLSeconds: 300 # challenge 存活時間 - MaxAttempts: 5 # 單 challenge 最大錯誤次數 - ResendCooldownSeconds: 60 # 重發冷卻 - DailyVerifyLimit: 10 # 每日上限 + Length: 6 + TTLSeconds: 300 + MaxAttempts: 5 + ResendCooldownSeconds: 60 + DailyVerifyLimit: 10 TOTP: Issuer: CloudEP Algorithm: SHA1 Digits: 6 PeriodSeconds: 30 - Window: 1 # ±1 time step 容忍 + Window: 1 BackupCodeCount: 10 BackupCodeLength: 12 EnrollTTLSeconds: 600 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 注入 ```go -// internal/svc/service_context.go -sc.MemberOTP // domusecase.OTPUseCase (一定有) -sc.MemberVerifyRate // domusecase.VerifyRateUseCase (一定有) -sc.MemberProfile // domusecase.ProfileUseCase (Mongo 設定後) -sc.MemberLifecycle // domusecase.LifecycleUseCase (Mongo 設定後) -sc.MemberTenant // domusecase.TenantUseCase (Mongo 設定後) -sc.MemberProvisioning // domusecase.ProvisioningUseCase(Mongo 設定後) -sc.MemberTOTP // domusecase.TOTPUseCase (TOTP.SecretKEK 設定後;否則 nil) +sc.MemberOTP // 一定有(Redis 必填) +sc.MemberVerifyRate // 一定有 +sc.MemberProfile // Mongo 啟用後 +sc.MemberLifecycle // Mongo 啟用後 +sc.MemberTenant // Mongo 啟用後 +sc.MemberProvisioning // Mongo 啟用後 +sc.MemberTOTP // TOTP.SecretKEK 設定後,否則 nil ``` -Logic 層使用前務必檢查可能 `nil` 的欄位: - ```go if sc.MemberTOTP == nil { return errb.SysNotImplemented("member TOTP not configured") @@ -577,7 +245,7 @@ if sc.MemberTOTP == nil { ## 測試 -### 單元測試 +### 單元 ```bash 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 | | `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL | -### 本機 API(P4) +### 本機 API ```bash -make deps-up # docker compose: mongo + redis -make mongo-index # 建索引 -make member-seed # 建 dev tenant + 一筆 member,輸出 X-Tenant-ID/X-UID headers -make run-local # 啟動 gateway +make deps-up && make mongo-index +make member-seed # 建 dev tenant + member +make run-dev -# Profile -curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \ +curl -s -H "Authorization: Bearer $TOKEN" \ 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(Google Authenticator) +### 互動式 TOTP ```bash -make deps-up -make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放 +make totp-test # STEP=flow:整套綁定 + 驗碼 + 重放 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` -- 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1 -- 統一錯誤格式(`errb.*`):`internal/library/errors/README.md` +- [`SDD.md`](./SDD.md) — Member 模組規格書(Data Dictionary、完整 API 端點) +- [`docs/model.md`](../../../docs/model.md) — Clean Architecture 分層 +- [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) — 跨模組設計 +- [`internal/library/errors/README.md`](../../library/errors/README.md) — 錯誤碼 diff --git a/internal/model/member/domain/const.go b/internal/model/member/domain/const.go index f43d9d1..857a201 100644 --- a/internal/model/member/domain/const.go +++ b/internal/model/member/domain/const.go @@ -40,7 +40,7 @@ const ( 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 ( UIDSequenceStart int64 = 10_000_000 UIDSequenceBucket int64 = 500 diff --git a/internal/model/member/domain/redis.go b/internal/model/member/domain/redis.go index 9239c4c..069ae21 100644 --- a/internal/model/member/domain/redis.go +++ b/internal/model/member/domain/redis.go @@ -7,8 +7,8 @@ import "strings" // concatenation should be avoided so the layout stays auditable. type RedisKey string -// Key prefixes for the member module. Layout matches identity-member-design.md -// section 14 (Redis Key 命名). +// Key prefixes for the member module. Layout matches +// internal/model/member/SDD.md §4.1 (Redis Keys). const ( OTPChallengeRedisKey RedisKey = "member:otp:challenge" VerifyRateRedisKey RedisKey = "member:verify:rate" diff --git a/internal/model/member/domain/usecase/totp.go b/internal/model/member/domain/usecase/totp.go index feb737f..e527de9 100644 --- a/internal/model/member/domain/usecase/totp.go +++ b/internal/model/member/domain/usecase/totp.go @@ -4,10 +4,10 @@ import "context" // TOTPUseCase manages business-tier RFC 6238 TOTP enrollment and verification. // -// The contract mirrors identity-member-design.md §5.8: enrollment is split in -// two steps (start → confirm) so the secret is only committed after the user -// proves possession; backup codes are returned exactly once on confirmation -// and replenished via RegenerateBackupCodes. +// The contract mirrors internal/model/member/SDD.md §3.5: enrollment is split +// in two steps (start → confirm) so the secret is only committed after the +// user proves possession; backup codes are returned exactly once on +// confirmation and replenished via RegenerateBackupCodes. type TOTPUseCase interface { // StartEnroll generates a fresh secret, stashes it in a short-lived cache, // and returns the otpauth URL for QR rendering. Calling it twice replaces diff --git a/internal/model/member/totp/totp.go b/internal/model/member/totp/totp.go index 872491b..7be6d12 100644 --- a/internal/model/member/totp/totp.go +++ b/internal/model/member/totp/totp.go @@ -25,8 +25,8 @@ import ( "time" ) -// Defaults match the configuration documented in identity-member-design.md -// section 5.8 / etc/gateway.yaml TOTP block. +// Defaults match the configuration documented in internal/model/member/SDD.md +// §3.5 / etc/gateway.yaml TOTP block. const ( DefaultDigits = 6 DefaultPeriod = 30 * time.Second diff --git a/internal/model/notification/README.md b/internal/model/notification/README.md index 3e0691d..4febf6a 100644 --- a/internal/model/notification/README.md +++ b/internal/model/notification/README.md @@ -2,6 +2,8 @@ 統一對外通知入口(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/model.md](../../../docs/model.md) — domain 分包、Redis/Mongo 生命週期 -- [docs/identity-member-design.md §11](../../../docs/identity-member-design.md#11-notification-module) — 產品設計 -- [etc/README.md](../../../etc/README.md) — Gateway 設定 -- [internal/library/redis/README.md](../../library/redis/README.md) -- [internal/library/mongo/README.md](../../library/mongo/README.md) +- [`docs/notification-testing.md`](../../../docs/notification-testing.md) — `notify-test` METHOD 速查 +- [`docs/model.md`](../../../docs/model.md) — domain 分包、Redis/Mongo 生命週期 +- [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §8 — 在跨模組流程中的角色 +- [`etc/README.md`](../../../etc/README.md) — Gateway 設定 +- [`internal/library/redis/README.md`](../../library/redis/README.md) +- [`internal/library/mongo/README.md`](../../library/mongo/README.md) diff --git a/internal/model/permission/README.md b/internal/model/permission/README.md index 7060125..ac8e633 100644 --- a/internal/model/permission/README.md +++ b/internal/model/permission/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 flowchart LR - subgraph Platform["平台層 (Platform-wide)"] + subgraph Platform["平台層"] Catalog[Permission Catalog] end - subgraph Tenant["租戶層 (per-tenant)"] + subgraph Tenant["租戶層"] Role[Role] RP[RolePermission] UR[UserRole] @@ -25,33 +29,32 @@ flowchart LR Casbin -- Check --> Middleware[CasbinRBAC Middleware] ``` -- Permission **平台 seed 全局**(`cmd/permission-seed`),租戶不可新增;只能勾選。 -- Role / RolePermission / UserRole **租戶獨立**;同名 role 可在不同租戶共存。 -- Role.Key 一旦建立 **不可改**;外部 IdP(ZITADEL / LDAP / SCIM)以 Key 作對應。 -- 多 pod 同步:**Redis Pub/Sub 即時通知 + 5min cron 兜底**。 +- Permission **平台 seed 全局**(`cmd/permission-seed`);租戶不可新增、只能勾選 +- Role / RolePermission / UserRole **租戶獨立**;同名 role 跨租戶共存 +- Role.Key 一旦建立 **不可改**(外部 IdP mapping 直接綁 key) +- 多 pod 同步:**Redis Pub/Sub 即時 + 5min cron 兜底** --- -## 1. 核心概念 +## 核心概念 | 概念 | 簡述 | 關鍵欄位 | |------|------|----------| | **Permission** | 平台級權限節點(樹狀,dot notation) | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy | -| **Role** | 租戶內的角色 | `tenant_id + key` unique;`is_system=true` 不可刪 | -| **RolePermission** | Role 勾選了哪些 Permission | 自動補齊 parent permission ID | -| **UserRole** | 使用者被指派的角色(多角色) | `source` 區分 manual / zitadel / ldap / scim | -| **RoleMapping** | 外部 group/role → 內部 Role.Key | SyncFromX 用來翻譯 IdP claims | -| **Casbin Policy** | 物化後的授權規則(Redis Set) | `(tenant, role, path, methods, name)` | +| **Role** | 租戶內角色 | `(tenant_id, key)` unique;`is_system=true` 不可刪 | +| **RolePermission** | Role 勾選的 Permission | 自動補齊 parent permission | +| **UserRole** | 使用者被指派的角色 | `source` ∈ {manual / zitadel / ldap / scim} | +| **RoleMapping** | 外部 group → 內部 Role.Key | SyncFromX 用來翻譯 IdP claims | +| **Casbin Rule** | 物化後規則 | `[tenant, role.key, http_path, http_methods, perm.name]` | -### 1.1 Permission Tree 範例 +### Permission Tree 範例 ``` member.info.management ← 分類(無 HTTP) ├── member.basic.info ← 二級分類 │ ├── member.info.select GET /api/v1/members/me │ └── member.info.update PATCH /api/v1/members/me -├── member.admin.list GET /api/v1/members -└── member.admin.read GET /api/v1/members/:uid +└── member.admin.list GET /api/v1/members permission.role.management ← 分類 ├── 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* ``` -> 分類節點(無 `http_path`)**不會**寫入 Casbin policy;它們只是 UI 樹狀渲染與 parent closure 用。 +> 分類節點(無 `http_path`)**不會**寫入 Casbin policy;只是 UI 樹狀渲染 + parent closure 用。 --- -## 2. 目錄結構 +## 目錄結構 ``` internal/model/permission/ -├── README.md # 本文件 -├── config/ -│ └── config.go # CasbinConfig / CacheConfig / ReloadConfig +├── README.md +├── config/ # CasbinConfig / CacheConfig / ReloadConfig ├── domain/ -│ ├── const.go # BSON 欄位 / Casbin / Role.Key 規則 -│ ├── errors.go # 模組共用 sentinel errors -│ ├── redis.go # Redis key helpers (casbin / user_roles / role_perms) -│ ├── entity/ -│ │ ├── permission.go # Permission catalog node -│ │ ├── role.go -│ │ ├── role_permission.go -│ │ ├── user_role.go -│ │ └── role_mapping.go -│ ├── 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 +│ ├── const.go # BSON 欄位、Casbin / Role.Key 規則 +│ ├── errors.go +│ ├── redis.go # casbin / user_roles / role_perms key +│ ├── entity/ # Permission / Role / RolePermission / UserRole / RoleMapping +│ ├── enum/ # Status / PermissionType / RoleSource +│ ├── repository/ # 5 個 repo 介面 + Casbin adapter port +│ └── usecase/ # 7 個 usecase 介面 + DTO +├── repository/ # Mongo + Redis(casbin policy Set) +├── usecase/ # 7 個 atomic(含 permission_tree / rbac) └── seed/ - ├── catalog.go # embed + Apply + DefaultSystemRoles - └── catalog.json # 平台 seed 資料 + ├── catalog.go # embed + Apply + DefaultSystemRoles + └── catalog.json # 平台 seed ``` --- -## 3. 模組依賴 +## 7 個 UseCase -```mermaid -flowchart TD - Logic[logic/permission] --> SVC[svc.ServiceContext] - SVC --> AuthQ[AuthorizationQueryUseCase] - SVC --> Perm[PermissionUseCase] - SVC --> Role[RoleUseCase] - SVC --> RolePerm[RolePermissionUseCase] - SVC --> UserRole[UserRoleUseCase] - SVC --> Mapping[RoleMappingUseCase] - 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] -``` +| UseCase | 主要方法 | +|---------|---------| +| `PermissionUseCase` | `GetCatalogTree` / `List` / `UpsertCatalog` / `UpdateStatus` | +| `RoleUseCase` | `Create` / `Get` / `List` / `Update` / `Delete` | +| `RolePermissionUseCase` | `List` / `Replace`(含 parent closure + Pub/Sub reload) | +| `UserRoleUseCase` | `Assign` / `Revoke` / `List` / `ReplaceForSource` | +| `RoleMappingUseCase` | `Upsert` / `Delete` / `GetByExternal` / `List` | +| `AuthorizationQueryUseCase` | `Me` | +| `RBACUseCase` | `Check` / `LoadPolicy` / `LoadAllPolicies` / `BroadcastReload` / Pub/Sub 訂閱 | --- -## 4. UseCase 介面(7 個) +## 資料儲存 -| UseCase | 主要方法 | 注入 | -|---------|----------|------| -| `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 | +### MongoDB ---- +| 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 - -| 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 +### Redis | Key | 內容 | TTL | 由誰寫 | |-----|------|-----|--------| -| `permission:casbin:rules:{tenant_id}` | Set of JSON-encoded `[]string` rules | 永久 | `RBACUseCase.LoadPolicy` / `BroadcastReload` | -| `perm:user_roles:{tenant_id}:{uid}` | List of role keys(讀取快取,預留) | `Cache.UserRolesTTLSeconds` | 預留 | -| `perm:role_perms:{tenant_id}:{role_id}` | List of permission names(預留) | `Cache.RolePermsTTLSeconds` | 預留 | -| `permission:tree:open` | 序列化的全局 open tree(預留) | `Cache.CatalogTTLSeconds` | 預留 | -| (channel) `casbin:reload` | Pub/Sub payload `{tenant_id, ts}` | — | `RBACUseCase.BroadcastReload` | +| `permission:casbin:rules:{tenant_id}` | Set of JSON Casbin rules | 永久 | `RBAC.LoadPolicy` / `BroadcastReload` | +| `perm:user_roles:{tenant_id}:{uid}` | role keys 快取(預留) | `Cache.UserRolesTTLSeconds` | — | +| `perm:role_perms:{tenant_id}:{role_id}` | permission names 快取(預留) | `Cache.RolePermsTTLSeconds` | — | +| (channel) `casbin:reload` | Pub/Sub payload `{tenant_id, ts}` | — | `RBAC.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 sequenceDiagram - participant Boot as svc.NewServiceContext - participant Mod as permission.NewModuleFromParam - participant Cfg as config.Defaults() - participant Repo as Mongo Repos (5) - participant Casbin as RBACUseCase - participant Redis as PolicyAdapter - - 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 原子化 + API->>UC: Replace(tenantID, roleID, ids) + UC->>Roles: GetByID(tenant check) + UC->>Perms: GetAll(catalog 全表) + UC->>UC: ids ⊆ catalog? + UC->>UC: getFullParentPermissionIDs(ids) + UC->>RP: SetForRole(DeleteMany + InsertMany 原子) UC->>RBAC: BroadcastReload(tenantID) - RBAC-->>UC: ok(fire-and-forget) - UC-->>Logic: nil - Logic-->>API: 200 OK + UC-->>API: nil ``` -### 6.5 UserRole 指派 / 撤銷 +### 2. SyncFromX(外部 IdP 同步) ```mermaid sequenceDiagram - participant API as POST /permissions/users/:uid/roles - 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) + Sync->>Map: GetByExternal(tenant, source, externalKey) Map-->>Sync: RoleMapping(internal_role_key) - Note over Sync: 收齊 IdP 端所有 roles → keys - Sync->>UC: ReplaceForSource(tenant, uid, source=zitadel, [roleKeys]) + Note over Sync: 收齊 IdP 端 → keys + Sync->>UC: ReplaceForSource(tenant, uid, source, [roleKeys]) UC->>UC: 阻擋 source==manual(防誤洗) - loop key in roleKeys - UC->>Roles: GetByKey (skip 不存在的) - end - UC->>URR: ReplaceForSource(tenant, uid, source, [roleIDs]) - Note over URR: DeleteMany source=zitadel + BulkInsert
※ source=manual 紀錄不動 + UC->>URR: DeleteMany source + BulkInsert UC->>RBAC: BroadcastReload(tenant) ``` -### 6.7 LoadPolicy(Casbin 規則載入) +### 3. LoadPolicy + Check ```mermaid 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) RBAC->>Roles: ListByTenant RBAC->>RP: ListByRoles(roleIDs) - RBAC->>Perms: GetByIDs(unique perm ids) + RBAC->>Perms: GetByIDs(unique perm ids) RBAC->>RBAC: 過濾 IsLeaf() && Status=open - RBAC->>RBAC: rules = [tenant, role.key, http_path, http_methods, perm.name] - RBAC->>Enf: ClearPolicy + AddPolicies - RBAC->>Adp: SaveAll(tenant, rules) ← Redis pipelined DEL+SADD - RBAC-->>Trigger: nil -``` + RBAC->>Enforcer: ClearPolicy + AddPolicies + RBAC->>Adapter: SaveAll → Redis pipelined DEL+SADD -### 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} - RBAC->>RBAC: enforcerFor(tenant)(lazy clone model + AddPolicies) - RBAC->>URR: ListByUser(tenant, uid) - RBAC->>Roles: ListByTenantAndIDs(過濾 status=open) + RBAC->>URR: ListByUser → ListByTenantAndIDs(過濾 status=open) loop role in roles(any-allow) - RBAC->>Enf: EnforceEx(tenant, role.key, path, method) - alt allow - RBAC-->>MW: CheckResult{Allow=true, MatchedRoleKey, MatchedPolicyRow} - end + RBAC->>Enforcer: EnforceEx 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 sequenceDiagram - participant PodA as Pod A (Replace) - participant Redis - participant PodB as Pod B (Subscribe) - participant PodC as Pod C (Subscribe) - - PodA->>PodA: RolePermission.Replace + LoadPolicy(本地) + PodA->>PodA: RolePermission.Replace + LoadPolicy PodA->>Redis: PUBLISH casbin:reload {tenant, ts} Redis-->>PodB: 推 message Redis-->>PodC: 推 message @@ -428,37 +192,11 @@ sequenceDiagram Note over PodB,PodC: 2-3ms 內三個 pod 同步 ``` -> 兜底:每個 pod 可定時跑 `LoadAllPolicies`(5min cron,未在本模組內排程;建議 svc 層或 cron-worker 觸發)。掃 Redis `permission:casbin:rules:*` key 推導 tenant 列表。 - -### 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 -``` +> 兜底:每個 pod 可定時 `LoadAllPolicies`(5min cron,建議 svc 層或 cron-worker 觸發;本模組不內建)。 --- -## 7. Casbin 模型(`etc/rbac.conf`) +## Casbin 模型(`etc/rbac.conf`) ```ini [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) ``` -- `keyMatch2`:支援 `/api/v1/members/*` 萬用 path -- `regexMatch`:`GET|POST|PATCH` 多 method 同一 policy -- 平台 Admin bypass 不寫進 matcher,由 middleware 預檢(保留 audit) +- `keyMatch2`:支援 `/api/v1/members/*` +- `regexMatch`:`GET|POST|PATCH` 多 method 同 policy +- 平台 Admin bypass 不寫在 matcher,由 middleware 預檢(保留 audit) --- -## 8. ServiceContext 注入 +## ServiceContext 注入 ```go -sc.PermissionCatalog // Permission catalog reader (tree / list / status) -sc.PermissionRole // Role CRUD(含 system role 防呆) -sc.PermissionRolePermission // Replace(含 parent closure) -sc.PermissionUserRole // Assign / Revoke / ReplaceForSource -sc.PermissionRoleMapping // 外部 group → Role.Key -sc.PermissionAuthQuery // GET /me 用 -sc.PermissionRBAC // Casbin enforcer(Mongo+Redis 全到位才有) +sc.PermissionCatalog // PermissionUseCase +sc.PermissionRole // RoleUseCase(含 system role 防呆) +sc.PermissionRolePermission // RolePermissionUseCase +sc.PermissionUserRole // UserRoleUseCase +sc.PermissionRoleMapping // RoleMappingUseCase +sc.PermissionAuthQuery // AuthorizationQueryUseCase +sc.PermissionRBAC // RBACUseCase(Mongo+Redis 全到位才有) 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 | 說明 | -|--------|------|---------|------| -| GET | `/catalog` | `getPermissionCatalog` | 全局 Catalog(tree=true 取樹狀) | -| GET | `/me` | `getMePermissions` | 當前 user 的 role / permission map | -| GET | `/roles` | `listRoles` | 租戶角色清單 | -| POST | `/roles` | `createRole` | 建立角色(key 不可改) | -| PATCH | `/roles/:id` | `updateRole` | 更新 display_name / status(system role 限制) | -| DELETE | `/roles/:id` | `deleteRole` | 刪角色(system / 仍有指派 → 拒絕) | -| GET | `/roles/:id/permissions` | `getRolePermissions` | 角色目前的 permission 集合 | -| PUT | `/roles/:id/permissions` | `replaceRolePermissions` | 全量取代 + 補 parent + Pub/Sub reload | -| GET | `/users/:uid/roles` | `listUserRoles` | 使用者目前指派的 role | -| POST | `/users/:uid/roles` | `assignUserRole` | 指派角色(source 預設 manual) | -| DELETE | `/users/:uid/roles/:role_id` | `revokeUserRole` | 撤銷單一角色 | -| GET | `/role-mappings` | `listRoleMappings` | 外部映射列表(分頁) | -| PUT | `/role-mappings` | `upsertRoleMapping` | Upsert 外部 group → Role.Key | -| DELETE | `/role-mappings` | `deleteRoleMapping` | 刪除外部映射 | -| POST | `/policy/reload` | `reloadPolicy` | 強制重載(單租戶或 `*`) | +| Method | Path | Middleware | 說明 | +|--------|------|------------|------| +| GET | `/catalog` | AuthJWT | 全局 Catalog(tree/list) | +| GET | `/me` | AuthJWT | 當前 user roles + permissions | +| GET | `/roles` | AuthJWT+Casbin | 租戶角色清單 | +| POST | `/roles` | AuthJWT+Casbin | 建立角色 | +| PATCH | `/roles/:id` | AuthJWT+Casbin | 更新 display_name / status | +| DELETE | `/roles/:id` | AuthJWT+Casbin | 刪角色(system / 仍有指派 → 拒絕) | +| GET | `/roles/:id/permissions` | AuthJWT+Casbin | 角色 permission 集合 | +| PUT | `/roles/:id/permissions` | AuthJWT+Casbin | 全量取代 + parent closure + reload | +| GET | `/users/:uid/roles` | AuthJWT+Casbin | 使用者 role | +| POST | `/users/:uid/roles` | AuthJWT+Casbin | 指派 | +| DELETE | `/users/:uid/roles/:role_id` | AuthJWT+Casbin | 撤銷 | +| GET / PUT / DELETE | `/role-mappings` | AuthJWT+Casbin | 外部映射 CRUD | +| POST | `/policy/reload` | AuthJWT+Casbin | 強制重載(單 tenant 或 `*`) | -完整錯誤碼註解參見 `generate/api/permission.api`,由 `make gen-doc` 出 OpenAPI。 +完整 schema 見 `generate/api/permission.api`。 --- -## 10. 設定範例(`etc/gateway.dev.example.yaml`) +## 設定(`etc/gateway.dev.yaml`) ```yaml Permission: Casbin: - Enabled: false # 預設關閉,啟用後 RBAC enforcement 生效 + Enabled: false # 預設關閉 ModelPath: etc/rbac.conf - PolicyAdapter: auto # auto / redis / mongo + PolicyAdapter: auto # auto / redis / mongo Cache: UserRolesTTLSeconds: 300 RolePermsTTLSeconds: 300 @@ -541,103 +277,57 @@ Permission: --- -## 11. CLI / 操作指南 +## CLI / 操作 ```bash -# 1) 建索引 +# 建索引 make mongo-index -# 2) 撰寫 / 修改 catalog -$EDITOR internal/model/permission/seed/catalog.json - -# 3) 全平台 seed catalog(不為任何 tenant 建 role) +# Catalog seed(全平台) 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 -# 5) 多租戶 -go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001,TEN-100002 - -# 6) 只 reseed tenant role(catalog 已存在) +# 只 reseed tenant role(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 \ - -H "Content-Type: application/json" \ - -H "X-Tenant-ID: TEN-100001" -H "X-UID: TEN-100001-OWNER" \ + -H "Authorization: Bearer $TOKEN" \ -d '{"tenant_id": "*"}' ``` ---- - -## 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 才需要) +預設 5 個 system role:`tenant_owner` / `tenant_admin` / `member_manager` / `member` / `viewer`(`seed/catalog.go::DefaultSystemRoles`)。 --- -## 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` 然後新建 | -| Role `key` 改名 | **禁止** | 外部 IdP mapping 直接綁 key;改名會切斷映射 | -| `is_system` role 刪除 | 拒絕 | 平台預設角色保留 | -| `is_system` role 改 status | 拒絕 | 維持平台預期行為 | +| Permission `name` 改名 | 禁止 | 被 RolePermission / Casbin policy.name 引用;廢棄改 `status=close` | +| Role `key` 改名 | 禁止 | 外部 IdP mapping 直接綁 key | +| `is_system` 刪除 / 改 status | 拒絕 | 平台預設角色保留 | | `manual` source ReplaceForSource | 拒絕 | 防 SyncFromX 誤洗手動指派 | -| Permission 有 `*` 萬用 path | 不建議裸 `*`;至少帶資源根 | 防 keyMatch2 貪婪命中跨資源 | -| Casbin 多 enforcer | 一 tenant 一個 enforcer,lazy 建 | 比一個 enforcer + filtered policy 簡單,且記憶體可預測 | -| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時通知 + reboot 不漏 | -| Pub/Sub client | 獨立 go-redis,不走 go-zero pool | go-zero 沒包 Subscribe,且 Subscribe 會佔住 conn | -| Permission Catalog 改動 | seed CLI 即可(idempotent) | UI 端不直接改 catalog;seed JSON 是 SoT | +| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時 + reboot 不漏 | +| Pub/Sub client | 獨立 go-redis | go-zero 沒包 Subscribe | +| Catalog 改動 | seed CLI(idempotent) | catalog.json 是 SoT | --- -## 15. 後續工作 +## 測試 -| 項目 | 預估 | -|------|------| -| Platform admin allowlist + audit log | 後續 | -| RoleMapping 用 SyncFromX 落地(Zitadel / LDAP / SCIM)| 隨對應 SyncFromX usecase 推進 | -| Policy reload cron worker(5 min) | 取自 svc 啟動 ticker | -| Role permission 編輯 UI(不在 Gateway 內,由前端取資) | 前端 | -| 細粒度欄位過濾(`.plain_code` 變體) | logic 層額外查 sub-permission | +```bash +# 單元 +go test ./internal/model/permission/... + +# 整合(需 Mongo + Redis) +make deps-up +go test -tags=integration ./internal/model/permission/... + +# E2E(含 Casbin enforcement) +make e2e-casbin +``` + +E2E 細節:[`docs/e2e-testing.md`](../../../docs/e2e-testing.md)。 diff --git a/internal/model/permission/domain/const.go b/internal/model/permission/domain/const.go index cfbe3ea..b1806ff 100644 --- a/internal/model/permission/domain/const.go +++ b/internal/model/permission/domain/const.go @@ -57,7 +57,7 @@ const ( PolicyReloadAllToken = "*" ) -// Role.Key constraints (identity-member-design.md §6.5). +// Role.Key constraints. See internal/model/permission/SDD.md §3.3 (RBAC Model). const ( RoleKeyMinLength = 2 RoleKeyMaxLength = 64 diff --git a/internal/model/permission/domain/redis.go b/internal/model/permission/domain/redis.go index 725f280..7c0adf2 100644 --- a/internal/model/permission/domain/redis.go +++ b/internal/model/permission/domain/redis.go @@ -8,7 +8,7 @@ import "strings" type RedisKey string // Key prefixes for the permission module. Layout matches -// identity-member-design.md §14. +// internal/model/permission/SDD.md §4.1 (Redis Keys). const ( CasbinRulesRedisKey RedisKey = "permission:casbin:rules" UserRolesRedisKey RedisKey = "perm:user_roles"