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

1263 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Identity / Member / Permission 模組設計草稿
> **狀態**Draft待 Review
> **適用專案**Portal API GatewayPGW
> **參考實作**[app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)Casbin RBAC、Permission Tree、Role/RolePermission
> **最後更新**2026-05-19
> **前提**:全新 Gateway module不考慮舊版 member-server 遷移。
本文件描述 Gateway 內 **auth**、**member**、**permission** 三個業務模組的目標架構,整合 **ZITADEL**(身份)、**LDAP**(企業目錄)、**SCIM 2.0**(企業 provisioning支援 **多租戶****百萬級會員**(含單租戶 50 萬)。
模組分層與程式碼撰寫規範見 [model.md](./model.md)。
---
## 目錄
1. [設計目標與原則](#1-設計目標與原則)
2. [模組全景](#2-模組全景)
3. [外部系統分工](#3-外部系統分工)
4. [auth 模組](#4-auth-模組)
5. [member 模組](#5-member-模組)
6. [permission 模組B2B 自定義)](#6-permission-模組b2b-自定義)
7. [API 規劃](#7-api-規劃)
8. [Middleware 鏈](#8-middleware-鏈)
9. [核心流程](#9-核心流程)
10. [LDAP 與 SCIM](#10-ldap-與-scim)
11. [可讀 UID 設計](#11-可讀-uid-設計)
12. [資料模型與索引](#12-資料模型與索引)
13. [Redis Key 命名](#13-redis-key-命名)
14. [規模與性能100 萬+ / 單租戶 50 萬)](#14-規模與性能100-萬--單租戶-50-萬)
15. [目錄結構](#15-目錄結構)
16. [設定檔](#16-設定檔)
17. [實施順序](#17-實施順序)
18. [待決策事項](#18-待決策事項)
---
## 1. 設計目標與原則
### 1.1 目標
| 目標 | 說明 |
|------|------|
| 統一身份 | ZITADEL 作為 IdP含 LDAP IdP、Social Login |
| 業務會員 | Gateway `member` 模組管理 tenant-scoped profile |
| 細粒度授權 | Gateway `permission` 模組(**Casbin RBAC + Permission Tree****每個 B2B 租戶可自定義 Role 並勾選 Permission** |
| Token | go-zero JWT 驗證 + Redis 黑名單(只黑名單 JWT |
| 企業整合 | SCIM 2.0 + LDAP Directory SyncAD + OpenLDAP |
| 規模 | 全平台 100 萬+ 會員;單租戶可達 50 萬 |
| UID | 人類可讀、可口述,非 UUID / 亂碼 |
### 1.2 核心原則
1. **職責分離**
- `auth`你是誰Authentication
- `member`你的業務資料是什麼Profile
- `permission`你能做什麼Authorization
2. **LDAP 不做登入 bind**
- 登入驗證由 ZITADEL LDAP IdP 處理
- Gateway 的 LDAP client 僅供 Directory Syncread-only
3. **Token Exchange**
- 對外 API 只接受 Gateway 簽發的 CloudEP JWT
- ZITADEL OIDC token 僅在 `/auth/token/exchange` 使用一次
4. **租戶隔離**
- 所有持久化資料以 `tenant_id` 為邊界
- JWT `tenant_id` 與請求資源必須一致
5. **B2B 權限自定義**(參考 [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server)
- 平台 seed 全局 Permission Tree`http_path` / `http_method`
- 租戶建立自訂 Role從 Tree **勾選** Permission`RolePermission` + 自動補 parent
- API 授權由 **Casbin** 比對 `(role.Name, path, method)`
- B2C 租戶仅用 seed 模板
---
## 2. 模組全景
```
┌─────────────────────────────────────────────────────────────────┐
│ Portal API Gateway (go-zero) │
├─────────────────────────────────────────────────────────────────┤
│ generate/api/ │
│ auth.api · member.api · permission.api · tenant.api · scim.api│
├─────────────────────────────────────────────────────────────────┤
│ internal/middleware/ │
│ jwt_revoke · casbin_rbac · scim_auth · tenant_context │
├─────────────────────────────────────────────────────────────────┤
│ internal/model/ │
│ auth/ → Token 簽發、換票、登出、黑名單、auth_gen │
│ member/ → Profile、Identity、Tenant、UID、Directory Sync │
│ permission/ → Casbin RBAC、Permission Tree、RoleB2B 自定義)│
├─────────────────────────────────────────────────────────────────┤
│ internal/library/ │
│ zitadel/ · ldap/ · uid/ · casbin/RBAC enforcer 封裝) │
├─────────────────────────────────────────────────────────────────┤
│ internal/worker/ │
│ directory_sync/ │
└─────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
MongoDB Redis ZITADEL
(profile/role) (cache/blacklist) (identity/LDAP IdP)
```
### 2.1 模組依賴方向
```
handler → logic → model/{auth|member|permission}/usecaseinterface
repository → MongoDB / Redis
logic 不 import entity / repository見 model.md
auth → memberEnsureMember
auth → permissionSyncRolesFromClaims
member → auth停權時 RevokeAllForUser
permission → member可選驗證 uid 存在)
```
---
## 3. 外部系統分工
| 能力 | ZITADEL | Gateway auth | Gateway member | Gateway permission |
|------|---------|--------------|----------------|-------------------|
| 註冊 / 登入 | ✅ | 換票 | EnsureMember | SyncRoles |
| 密碼 / MFA / 忘記密碼 | ✅ | — | — | — |
| Google / LINE / Apple | ✅ IdP | — | — | — |
| LDAP 登入 | ✅ LDAP IdP | — | — | Group→Role 映射 |
| Access / Refresh Token對外 | — | ✅ CloudEP JWT | — | — |
| JWT 黑名單 | — | ✅ Redis | — | — |
| 業務 UID | — | — | ✅ | — |
| Profile | — | — | ✅ | — |
| 會員列表 / 狀態 | — | — | ✅ | 需授權 |
| API 細粒度權限 | 粗粒度 Role | — | — | **Casbin RBAC**path + method |
| SCIM Users/Groups | 可同步 | — | ✅ 業務寫入 | ✅ Group→Role |
| LDAP Directory Sync | — | — | ✅ Worker | ✅ Group→Role |
### 3.1 多租戶對應
```
1 CloudEP Tenant = 1 ZITADEL Organization = 1 資料隔離邊界
```
| 欄位 | 來源 | 用途 |
|------|------|------|
| `tenant_id` | ZITADEL `org_id` | 分片鍵、授權邊界 |
| `identity_id` | ZITADEL `sub` | 身份映射 |
| `uid` | Member 模組產生 | 業務會員 ID`ACME-ODWXGYBK` |
### 3.2 租戶類型
| 類型 | 登入 | LDAP | 權限 |
|------|------|------|------|
| **B2C** | Email / Social | 無 | 系統預設 Role不可或不常自定義 |
| **B2B** | ZITADEL → LDAP IdP | 有 | **完全自定義 Role + Permission** |
| **Hybrid** | Social + LDAP | 有 | B2B 自定義 + 外部客戶用預設 Role |
---
## 4. auth 模組
路徑:`internal/model/auth/`
### 4.1 職責
- 驗證 ZITADEL OIDC tokenid_token / authorization_code + PKCE
- 編排 `member.EnsureMember``permission.SyncRolesFromClaims`
- 簽發 CloudEP JWTaccess + refresh
- 登出jti 黑名單
- 批量失效:`auth_gen`(停權 / 改密碼 / 權限強制刷新)
### 4.2 UseCase 介面
```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
}
```
### 4.3 CloudEP JWT Claims
```go
type Claims struct {
jwt.RegisteredClaims // 含 jti, exp, iat
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
Roles string `json:"roles"` // 逗號分隔 Role.NameCasbin enforce 用
Typ string `json:"typ"` // access | refresh
AuthGen int64 `json:"auth_gen"` // 批量失效代號
}
```
### 4.4 JWT 設定go-zero
```yaml
Auth:
AccessSecret: ${JWT_ACCESS_SECRET}
AccessExpire: 900 # 15 分鐘
RefreshAuth:
AccessSecret: ${JWT_REFRESH_SECRET}
AccessExpire: 604800 # 7 天
```
`.api` 受保護路由:
```api
@server(jwt: Auth, middleware: JwtRevokeMiddleware)
```
### 4.5 黑名單策略(只黑名單 JWT
#### 單 Token 撤銷(登出)
```
Key: auth:jwt:bl:{jti}
Value: 1
TTL: token 剩餘有效時間exp - now
```
登出時同時黑名單 access + refresh 的 jti。
#### 批量失效(停權 / 改密碼 / SCIM deactivate / 角色強刷)
```
Key: auth:gen:{tenant_id}:{uid}
Value: 整數,預設 1事件發生時 INCR
```
Middleware 檢查:`token.auth_gen >= redis.auth_gen`,否則 401。
> JWT 內不放全部 permission避免 token 過大);批量失效用 `auth_gen`,單次登出用 jti 黑名單。
### 4.6 Middleware 檢查順序
```
1. go-zero JWT 驗簽 + exp
2. typ == "access"(受保護 API
3. NOT EXISTS auth:jwt:bl:{jti}
4. claims.auth_gen >= redis auth:gen:{tenant}:{uid}
5. 注入 contexttenant_id, uid, roles
```
---
## 5. member 模組
路徑:`internal/model/member/`
### 5.1 職責
- 會員 Profile CRUDtenant-scoped
- Identity 映射(`zitadel_sub` ↔ `uid`
- Tenant metadata 與 LDAP 同步設定
- UID 產生(可讀格式)
- SCIM 業務寫入User + externalId = uid
- Directory Sync WorkerAD + OpenLDAP
- 會員狀態active / suspended→ 通知 auth 撤銷 token
### 5.2 UseCase 介面
```go
type ProvisioningUseCase interface {
EnsureMember(ctx context.Context, req *EnsureMemberRequest) (*MemberDTO, error)
}
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)
}
type AdminUseCase interface {
UpdateStatus(ctx context.Context, req *UpdateStatusRequest) error
}
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
}
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
// Groups供 SCIM Group → Role 映射)
PatchGroup(ctx context.Context, req *ScimPatchGroupRequest) error
}
type DirectorySyncUseCase interface {
SyncTenant(ctx context.Context, tenantID string) (*SyncResult, error)
}
```
### 5.3 會員狀態
| 狀態 | 語意 | 副作用 |
|------|------|--------|
| `unverified` | 尚未完成業務驗證 | — |
| `active` | 正常使用 | — |
| `suspended` | 停權 | `auth.RevokeAllForUser` |
---
## 6. permission 模組B2B 自定義,參考 permission-server
路徑:`internal/model/permission/`
> 本節吸收 [app-cloudep-permission-server](https://code.30cm.net/digimon/app-cloudep-permission-server) 已驗證的設計:**Casbin + Redis RBAC**、**Permission Tree父子繼承**、**HTTP Path/Method 綁定**。
> **與舊 permission-server 的差異**Token 簽發/驗證/黑名單移至 Gateway `auth` 模組;`ClientID` 改為 `tenant_id`;支援多租戶 B2B 各自定義 Role。
### 6.1 設計目標
| 能力 | 說明 |
|------|------|
| **Permission Tree** | 全局權限樹(平台 seed父子節點繼承父節點關閉則子節點不可用 |
| **Casbin RBAC** | 以 `(role, http_path, http_method)` 做 API 授權path 支援 `keyMatch2` 萬用字元 |
| **B2B 自定義 Role** | 每個租戶建立自訂 Role從全局 Catalog **勾選** Permission不可自創 Permission 字串) |
| **UserRole** | 租戶 + uid + role支援多角色JWT 帶 role namesCasbin 逐一檢查) |
| **RolePermission** | 勾選子權限時自動補齊父權限 ID沿用 permission-server 的 `getFullParentPermissionIDs` |
| **Policy 同步** | MongoDB → Casbin Policy → Redis定時 `LoadPolicy` + 變更時觸發 reload |
| **外部映射** | ZITADEL Role / LDAP Group / SCIM Group → 租戶內部 Role |
| **細粒度擴展** | 同一 API 可掛 `.plain_code` 子權限(如明碼查詢),沿用舊設計 |
### 6.2 與 app-cloudep-permission-server 對照
| permission-server | Gateway permission 模組 | 備註 |
|-------------------|---------------------------|------|
| `TokenService` | **`auth` 模組** | JWT 不再放 permission-server |
| `PermissionService`(空) | 完整 HTTP API | 直接在 Gateway 暴露 |
| `entity.Permission` | 沿用 + `tenant_id` 不適用(全局 Catalog | Permission 為平台級 |
| `entity.Role.ClientID` | `Role.TenantID` | 租戶隔離 |
| `entity.Role.UID` | `Role.CreatorUID` | 建立者,可選 |
| `entity.Role.Name` | `Role.Name` | **Casbin policy 的 role 欄位** |
| `Casbin Enforcer` | `RBACUseCase` + Redis Adapter | 沿用 |
| `PermissionTree` | `usecase/permission_tree.go` | 沿用 |
| `AdminRoleUID` / `GodDog` | `PlatformSuperAdminUID` | 平台超級管理員 bypass |
| `permission.Type` | `enum.PermissionType` | `BackendUser` / `FrontendUser` |
### 6.3 核心概念
```
Permission全局樹 平台定義,含 name / http_path / http_method / parent / status / type
Role租戶自定義 租戶建立的命名角色,如 sales_supervisor、tenant_admin
RolePermission Role ↔ Permission ID 多對多;勾選時自動補父節點
UserRole uid ↔ Role一 user 可多 role
RoleMapping 外部 Group/Role → 內部 Role.Name
Casbin Policy p, {role.Name}, {http_path}, {http_method}, {permission.Name}
```
### 6.4 Permission Entity全局 Catalog
沿用 permission-server 的 `entity.Permission` 結構MongoDB collection`permission`。
```go
type Permission struct {
ID primitive.ObjectID
Parent string // 父權限 IDObjectID hex空 = 掛 root
Name string // 唯一語意名dot notation如 member.info.select
HTTPMethod string // GET / POST / PATCH / DELETE / PUT分類節點可為空
HTTPPath string // 如 /api/v1/members/*;分類節點可為空
Status enum.Status // open | close
Type enum.PermissionType // backend_user | frontend_user後台 / 前台菜單)
CreateAt int64
UpdateAt int64
}
```
#### 命名規則dot notation與 permission-server 一致)
```
{domain}.{module}.{action}
{domain}.{module}.{action}.{variant} # 如 .plain_code
```
#### Permission Tree 範例seed 草案)
```
member.info.management # 一級:會員資訊管理(分類,無 HTTP
├── member.basic.info # 二級:基礎資訊
│ ├── member.info.select # GET /api/v1/members/me
│ ├── member.info.update # PATCH /api/v1/members/me
│ └── member.info.select.plain_code # GET /api/v1/members明碼欄位
├── member.admin.list # GET /api/v1/members
├── member.admin.read # GET /api/v1/members/:uid
├── member.admin.update # PATCH /api/v1/members/:uid
└── member.admin.status # PATCH /api/v1/members/:uid/status
permission.role.management # 一級:角色權限管理
├── permission.role.read # GET /api/v1/permissions/roles
├── permission.role.write # POST/PUT/DELETE roles
├── permission.assign.write # POST/DELETE user roles
└── permission.catalog.read # GET /api/v1/permissions/catalog
tenant.management
├── tenant.read
├── tenant.ldap.write
└── tenant.sync.trigger
scim.management
├── scim.users.write
└── scim.groups.write
system.management # 平台級
└── system.tenant.create
```
> **分類節點**(無 `http_path`)供 UI 樹狀勾選;**葉節點**才寫入 Casbin Policy。
> 新增 Permission 走平台 seed migration租戶**不可**自行新增 Permission 名稱。
#### Permission Tree 行為(沿用 permission-server
1. **`filterOpenNodes`**:父節點 `status=close` → 整棵子樹不可用
2. **`getFullParentPermissionIDs`**:勾選子權限 → 自動加入所有父節點 ID
3. **`getFullParentPermission`**:查 Role 權限 → 回傳含父節點的完整 permission name → status map供前端 UI
### 6.5 Role EntityB2B 租戶自定義)
```go
type Role struct {
ID primitive.ObjectID
TenantID string // 租戶 ID= 舊 ClientID
Name string // 角色名稱租戶內唯一Casbin enforce 用此值
CreatorUID string // 建立者 uid= 舊 Role.UID可選
Status enum.Status // open | close
IsSystem bool // 系統 seed 的預設角色B2B 可改 Permission 但不可刪除 Owner
CreateAt int64
UpdateAt int64
}
// Index: { tenant_id, name } unique
```
#### B2B 自定義規則
1. 租戶可 **CRUD** 自訂 Role`is_system=false`
2. 系統 seed 的預設 Role`is_system=true`)可修改 Permission 集合,**tenant_owner 不可刪**
3. Role 綁定的 Permission 必須是全局 Catalog 中 `status=open` 的節點
4. 租戶**不可**勾選 `system.*` 權限(除非平台另行開啟)
5. 至少保留一個 Role 含 `permission.role.write`,避免租戶自鎖
#### 預設 Role 模板(建立 B2B tenant 時 seed
| Name | 說明 | 預設勾選Permission Name |
|------|------|----------------------------|
| `tenant_owner` | 租戶擁有者 | 除 `system.*` 外全部 open 節點 |
| `tenant_admin` | 租戶管理員 | member.*, permission.*, tenant.*, scim.* |
| `member_manager` | 會員管理 | member.admin.list, member.admin.read, member.admin.status |
| `member` | 一般會員 | member.info.select, member.info.update |
| `viewer` | 唯讀 | member.info.select |
B2B 管理員範例:
```
建立 Rolesales_supervisor
勾選member.admin.list, member.admin.read
指派POST /permissions/users/{uid}/roles { "role_name": "sales_supervisor" }
→ RolePermission.Create → getFullParentPermissionIDs 自動補 parent
→ LoadPolicy 刷新 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 {
RoleID string
PermissionID string
CreateAt int64
UpdateAt int64
}
// Index: { role_id, permission_id } unique
```
> 舊 permission-server 的 UserRole 為一 user 一 roleUpdate 覆蓋);新設計**支援多角色**Middleware 對每個 role name 做 Casbin enforce任一 allow 即通過。
### 6.7 Casbin RBAC核心授權引擎
#### 模型檔 `etc/rbac.conf`(沿用 permission-server
```ini
[request_definition]
r = role, path, method
[policy_definition]
p = role, path, methods, name
[policy_effect]
e = some(where (p.eft == allow))
[role_definition]
g = _, _
[matchers]
m = g(r.role, p.role) && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods) \
|| r.role == "${PlatformSuperAdminUID}"
```
- **`keyMatch2`**:支援 `/api/v1/members/*` 萬用 path
- **`regexMatch`**:支援 `GET|POST` 多 method 寫在同一 policy
- **SuperAdmin bypass**:平台維運 uid 全放行(對應舊 `GodDog`
#### Policy 載入(`RBACUseCase.LoadPolicy`
```
1. permissionRepo.GetAll → GeneratePermissionTree → filterOpenNodes
2. roleRepo.All(tenant_id) → 每個 role 取 rolePermissionRepo.Get
3. 對每個 (role, permission) 若 http_path + http_method 非空:
enforcer.AddPolicy(role.Name, permission.HTTPPath, permission.HTTPMethod, permission.Name)
4. adapter.SavePolicy → Redis Listcasbin rules
5. enforcer.LoadPolicy()
```
#### 授權檢查(`RBACUseCase.Check`
```go
// 輸入roleName, requestPath, requestMethod
ok, policy, err := enforcer.EnforceEx(roleName, path, method)
// 回傳 CheckRolePermissionStatus
// Allow: bool
// PermissionName: string // 命中的 permission.Name
// PlainCode: bool // 是否有 .plain_code 子權限GET 時額外查)
```
#### Policy 同步策略
| 觸發 | 動作 |
|------|------|
| RolePermission 變更 | 該 tenant `LoadPolicy` |
| Permission status 變更(平台) | 全局 `LoadPolicy` |
| 定時 cron如 5min | `SyncPolicy` 兜底 |
| Gateway 啟動 | 初始 `LoadPolicy` |
Redis 儲存 Casbin rules`permission:casbin:rules`List of JSON `rbac.Rule`
### 6.8 UseCase 介面
```go
// --- Casbin 授權(核心)---
type RBACUseCase interface {
Check(ctx context.Context, req *CheckRequest) (*CheckResult, error)
LoadPolicy(ctx context.Context, tenantID string) error
LoadAllPolicies(ctx context.Context) error
SyncPolicy(ctx context.Context, interval time.Duration)
}
type CheckRequest struct {
TenantID string
UID string
Path string // 實際請求 path
Method string // 實際 HTTP method
}
type CheckResult struct {
Allow bool
PermissionName string
PlainCode bool
MatchedRole string
}
// --- Permission Catalog平台級---
type PermissionUseCase interface {
All(ctx context.Context, status *enum.Status) ([]PermissionDTO, error)
FilterAll(ctx context.Context) ([]PermissionDTO, error) // 樹狀過濾 open 節點
Insert(ctx context.Context, req *CreatePermissionRequest) error // 平台 Admin
Update(ctx context.Context, id string, req *UpdatePermissionRequest) error
}
// --- Role租戶級B2B 自定義)---
type RoleUseCase interface {
List(ctx context.Context, req *ListRolesRequest) ([]RoleDTO, int64, error)
All(ctx context.Context, tenantID string) ([]RoleDTO, error)
GetByID(ctx context.Context, tenantID, id string) (*RoleDTO, error)
Create(ctx context.Context, req *CreateRoleRequest) error
Update(ctx context.Context, id string, req *UpdateRoleRequest) error
Delete(ctx context.Context, tenantID, id string) error
}
// --- RolePermission ---
type RolePermissionUseCase interface {
Get(ctx context.Context, roleID string) (enum.Permissions, error) // name → open/close
Create(ctx context.Context, roleID string, perms enum.Permissions) error
Delete(ctx context.Context, roleID string, perms enum.Permissions) error
}
// --- UserRole ---
type UserRoleUseCase interface {
GetByUID(ctx context.Context, tenantID, uid string) ([]UserRoleDTO, error)
Assign(ctx context.Context, tenantID, uid, roleID string) error
Revoke(ctx context.Context, tenantID, uid, roleID string) error
Replace(ctx context.Context, tenantID, uid string, roleIDs []string) error
}
// --- 外部映射 ---
type RoleMappingUseCase interface {
List(ctx context.Context, tenantID string) ([]RoleMappingDTO, error)
Upsert(ctx context.Context, req *UpsertRoleMappingRequest) error
Delete(ctx context.Context, tenantID, id string) error
SyncFromZitadelClaims(ctx context.Context, req *SyncFromZitadelRequest) error
SyncFromScimGroup(ctx context.Context, req *SyncFromScimGroupRequest) error
SyncFromLDAPGroups(ctx context.Context, req *SyncFromLDAPGroupsRequest) error
}
// --- 聚合查詢(前端菜单)---
type AuthorizationQueryUseCase interface {
GetMyPermissions(ctx context.Context, tenantID, uid string) (enum.Permissions, error)
GetMyRoles(ctx context.Context, tenantID, uid string) ([]string, error)
}
```
### 6.9 Middleware 授權流程
```
RequestJWT 已驗證)
1. 取 ctx.tenant_id, ctx.uid
2. userRoleUC.GetByUID → []Role.Name
3. 對每個 roleName
rbacUC.Check(tenantID, uid, roleName, r.URL.Path, r.Method)
4. 任一 Allow=true → 通過,注入 ctx.permission_name, ctx.plain_code
5. 全否 → 403 Forbidden
6. SuperAdmin uid → 直接通過
```
Logic 層可讀 `ctx.plain_code` 決定是否回傳明碼欄位(沿用 permission-server 的 `PlainCode` 模式)。
### 6.10 外部 Group / Role 映射
```go
type RoleMapping struct {
TenantID string
ExternalSource enum.RoleSource // zitadel | ldap | scim
ExternalKey string // ZITADEL role / LDAP group DN / SCIM group id
InternalRole string // 租戶 Role.Name
}
// Index: { tenant_id, external_source, external_key } unique
```
| 來源 | ExternalKey 範例 | 映射到 |
|------|------------------|--------|
| ZITADEL | `org_admin` | `tenant_admin` |
| LDAP (AD) | `CN=CloudEP-Admins,OU=Groups,DC=acme,DC=com` | 租戶自訂 Role.Name |
| LDAP (OpenLDAP) | `cn=admins,ou=groups,dc=acme,dc=com` | 租戶自訂 Role.Name |
| SCIM Group | `group-uuid-xxx` | 租戶自訂 Role.Name |
由 B2B 租戶管理員在後台設定(需命中 `permission.role.write` 對應 API
### 6.11 權限變更生效
| 事件 | 動作 |
|------|------|
| RolePermission Create/Delete | `LoadPolicy(tenant_id)` |
| Role Create/Update/Delete | `LoadPolicy(tenant_id)` |
| UserRole Assign/Revoke | 可選 `INCR auth:gen`(立即踢下线) |
| SCIM / LDAP Group 變更 | 更新 user_roles → `LoadPolicy` + `INCR auth_gen` |
| Permission status 變更(平台) | `LoadAllPolicies()` |
### 6.12 B2C vs B2B 權限策略
| 租戶類型 | Role 自定義 | Permission 勾選 |
|----------|-------------|-----------------|
| **B2C** | 不可(只用 seed 模板) | 固定 |
| **B2B** | **完全自定義** | 從全局 Catalog 自由勾選 |
| **Hybrid** | B2B 部分可自定義 | 依 tenant 設定 |
---
## 7. API 規劃
檔案:`generate/api/`
### 7.1 auth.api公開
| Method | Path | 說明 |
|--------|------|------|
| POST | `/api/v1/auth/token/exchange` | ZITADEL token → CloudEP JWT |
| POST | `/api/v1/auth/token/refresh` | 刷新 JWT |
| POST | `/api/v1/auth/logout` | 登出jti 黑名單) |
### 7.2 member.api需 JWT + Casbin
| Method | Path | Casbin 命中 Permission示例 |
|--------|------|-------------------------------|
| GET | `/api/v1/members/me` | `member.info.select` |
| PATCH | `/api/v1/members/me` | `member.info.update` |
| GET | `/api/v1/members` | `member.admin.list` |
| GET | `/api/v1/members/:uid` | `member.admin.read` |
| PATCH | `/api/v1/members/:uid` | `member.admin.update` |
| PATCH | `/api/v1/members/:uid/status` | `member.admin.status` |
> 授權由 **Casbin 比對實際 path + method** 決定,非硬編碼 permission 字串。
### 7.3 permission.api需 JWT + Casbin
| Method | Path | 說明 |
|--------|------|------|
| GET | `/api/v1/permissions/catalog` | 全局 Permission Treeopen 節點) |
| GET | `/api/v1/permissions/me` | 當前用户的 permission name → status map |
| GET | `/api/v1/permissions/roles` | 列出租戶 Role |
| POST | `/api/v1/permissions/roles` | 建立 RoleB2B |
| PUT | `/api/v1/permissions/roles/:id` | 更新 Role |
| DELETE | `/api/v1/permissions/roles/:id` | 刪除 Role |
| GET | `/api/v1/permissions/roles/:id/permissions` | 取得 Role 勾選的 Permission |
| PUT | `/api/v1/permissions/roles/:id/permissions` | 更新 Role 勾選PermissionTree 驗證 + 補 parent |
| GET | `/api/v1/permissions/users/:uid/roles` | 查用户角色 |
| POST | `/api/v1/permissions/users/:uid/roles` | 指派 Role `{ "role_id": "..." }` |
| DELETE | `/api/v1/permissions/users/:uid/roles/:role_id` | 撤銷 Role |
| GET | `/api/v1/permissions/role-mappings` | 外部 Group 映射列表 |
| PUT | `/api/v1/permissions/role-mappings` | 新增/更新映射 |
| POST | `/api/v1/permissions/policy/reload` | 手動觸發 LoadPolicy平台 Admin |
### 7.4 tenant.api平台 / 租戶 Admin
| Method | Path | Casbin 命中 Permission示例 |
|--------|------|-------------------------------|
| POST | `/api/v1/admin/tenants` | `system.tenant.create` |
| GET | `/api/v1/admin/tenants/:tenant_id` | `tenant.read` |
| PUT | `/api/v1/admin/tenants/:tenant_id/ldap` | `tenant.ldap.write` |
| POST | `/api/v1/admin/tenants/:tenant_id/directory-sync` | `tenant.sync.trigger` |
### 7.5 scim.apiSCIM Bearer Token非 JWT
```
/scim/v2/tenants/{tenant_id}/Users
/scim/v2/tenants/{tenant_id}/Groups
```
認證:`Authorization: Bearer {tenant_scim_token}`hash 存於 tenant 設定)
SCIM 請求授權可透過 tenant 級 token 隱含 `scim:*`,或細分 scim users/groups permission。
---
## 8. Middleware 鏈
### 8.1 一般受保護 API
```
Request
→ go-zero JWT 驗簽
→ JwtRevokeMiddlewarejti 黑名單 + auth_gen
→ TenantContextMiddleware校驗 tenant_id 一致)
→ CasbinRBACMiddlewarerole names × path × method → Allow
→ handler → logic → usecase
```
### 8.2 CasbinRBACMiddleware
```go
// 伪代码
roles := userRoleUC.GetRoleNames(ctx, tenantID, uid)
for _, roleName := range roles {
result, _ := rbacUC.Check(ctx, &CheckRequest{
TenantID: tenantID, UID: uid,
RoleName: roleName, Path: r.URL.Path, Method: r.Method,
})
if result.Allow {
ctx = withPermissionName(ctx, result.PermissionName)
ctx = withPlainCode(ctx, result.PlainCode)
next(w, r)
return
}
}
// SuperAdmin bypass
if uid == cfg.PlatformSuperAdminUID { next(w, r); return }
httpx.Error(w, forbidden)
```
### 8.3 SCIM API
```
Request
→ ScimAuthMiddlewaretenant_scim_token
→ TenantContextMiddleware
→ handler
```
### 8.4 Logic 層補充授權
Casbin 處理 **API 級** 授權。Logic 內可追加 **資源級** 判斷:
- `member.info.select` vs 查他人:若 path 含 `:uid` 且 uid ≠ caller需命中 `member.admin.read`
- `PlainCode`Logic 讀 `ctx.plain_code`,決定是否回傳明碼欄位
---
## 9. 核心流程
### 9.1 登入 / 換票
```
Client → ZITADEL OIDC Login含 LDAP IdP
Client → POST /auth/token/exchange { tenant_slug, id_token }
1. zitadel.VerifyIDToken
2. tenant.ResolveBySlug → 校驗 org_id
3. member.EnsureMember → uid如 ACME-ODWXGYBK
4. permission.SyncFromZitadelClaims → user_roles
5. auth.IssueTokenPairrole names 逗號分隔, auth_gen
Client ← { access_token, refresh_token, uid }
```
### 9.2 受保護 API
```
Client → GET /api/v1/members/me (Bearer access_jwt)
1. JWT + 黑名單 + auth_gen
2. CasbinRBACMiddleware → Check(role, "/api/v1/members/me", "GET")
3. member.GetByUID
```
### 9.3 B2B 自定義 Role + 勾選 Permission
```
Tenant Admin → PUT /permissions/roles/{id}/permissions
{ "member.admin.list": "open", "member.admin.read": "open" }
→ RolePermissionUC.Create
→ PermissionTree.getFullParentPermissionIDs自動補 parent
→ RBACUC.LoadPolicy(tenant_id)
Tenant Admin → POST /permissions/users/{uid}/roles
{ "role_id": "..." }
→ UserRoleUC.Assign
```
### 9.4 停權
```
Admin → PATCH /members/:uid/status { status: "suspended" }
→ member.UpdateStatus
→ auth.RevokeAllForUserINCR auth_gen
```
---
## 10. LDAP 與 SCIM
### 10.1 三條 Provisioning 路徑
| 路徑 | 適用 | 說明 |
|------|------|------|
| **SCIM → ZITADEL → Gateway** | 有 HR / Entra ID / Okta | 企業推送用户 |
| **ZITADEL LDAP IdP** | 用戶登入時 JIT | 首次登入建立 member |
| **Directory Sync Worker** | 無 SCIM 的 AD / OpenLDAP | 定時同步 + 離職偵測 |
### 10.2 LDAP 設定AD + OpenLDAP
```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
- `externalId` = Member UID`ACME-ODWXGYBK`
- SCIM Groups PATCH → `permission.SyncFromScimGroup`
- SCIM deactivate → `member.suspended` + `auth.RevokeAllForUser`
---
## 11. 可讀 UID 設計
### 11.1 格式
```
{TenantPrefix}-{Body}
範例ACME-ODWXGYBK
```
- `TenantPrefix`2~4 位大寫(來自 tenant.UIDPrefix / slug 縮寫)
- `Body`6~8 位大寫字母(自訂字母表,排除易混淆字元)
- **不要** UUID、不要純數字、不要 base64 亂碼
### 11.2 字母表(沿用 invited_code 風格)
```
O D W X Y G B C H E F A Q I J L M N Z K P V R S T
25 字符,無 0/O/1/I 混淆問題)
```
### 11.3 產生Bucket 取號,支援單租戶 50 萬)
```
Redis: member:seq:{tenant_id}
每次 INCRBY 500 取一個 bucket
bucket 內 sequential 編碼 → Body
```
唯一索引:`{ tenant_id, uid }` unique
---
## 12. 資料模型與索引
### 12.1 Collections
| Collection | 模組 | 說明 |
|------------|------|------|
| `members` | member | Profile |
| `identities` | member | zitadel_sub ↔ uid |
| `tenants` | member | 租戶 metadata |
| `tenant_ldap_configs` | member | LDAP 同步設定(加密) |
| `permissions` | permission | 全局 Permission Tree平台 seed |
| `roles` | permission | 租戶 Role`tenant_id` + `name` |
| `role_permissions` | permission | Role ↔ Permission ID |
| `user_roles` | permission | uid ↔ Role支援多角色 |
| `role_mappings` | permission | 外部 Group ↔ Role.Name |
### 12.2 主要索引
```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, name: 1 } // unique
{ tenant_id: 1, status: 1 }
// role_permissions
{ 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
```
### 12.3 分片鍵100 萬+
```
Shard Key: { tenant_id: 1, uid: 1 }
```
單租戶 50 萬會集中在同一 chunkMongoDB 仍可承受;若預期單租戶千萬級再評估 hash 二次分片。
---
## 13. Redis Key 命名
### auth`internal/model/auth/redis.go`
```
auth:jwt:bl:{jti} # 單 token 黑名單TTL = 剩餘壽命
auth:gen:{tenant_id}:{uid} # 批量失效代號
```
### member`internal/model/member/redis.go`
```
member:profile:{tenant_id}:{uid} # profile cacheTTL 5~15min
member:sub:{tenant_id}:{sub} # zitadel_sub → uidTTL 1h
member:seq:{tenant_id} # UID bucket counter
```
### permission`internal/model/permission/redis.go`
```
permission:casbin:rules # Casbin policy rulesList of JSON
permission:tree:open # 可選open 節點 cache
perm:role_perms:{tenant_id}:{role_id} # role → permission namesTTL 30min
perm:user_roles:{tenant_id}:{uid} # uid → role namesTTL 5min
```
---
## 14. 規模與性能100 萬+ / 單租戶 50 萬)
| 項目 | 策略 |
|------|------|
| Gateway | 無狀態,水平擴展 |
| MongoDB | Sharding + Replica Set讀走 secondary |
| ListMembers | Cursor 分頁,禁止 deep offset |
| Authorize | Casbin EnforceEx内存 + Redis policy |
| LoadPolicy | 变更时增量cron 5min 全量兜底 |
| JWT → UID | Redis cache 1h |
| Directory Sync | 500 users / batchrate limit ZITADEL API |
| Access Token TTL | 15min降低撤銷窗口 |
### 容量粗估
```
100 萬 members × ~2KB ≈ 2GB不含 index
indexes ≈ 1~2GB
→ 單集群可承受,建議 3 node replica set 起跳
```
---
## 15. 目錄結構
```
gateway/
├── generate/api/
│ ├── auth.api
│ ├── member.api
│ ├── permission.api
│ ├── tenant.api
│ └── scim.api
├── internal/
│ ├── middleware/
│ │ ├── jwt_revoke.go
│ │ ├── tenant_context.go
│ │ ├── require_permission.go
│ │ └── scim_auth.go
│ │
│ ├── library/
│ │ ├── zitadel/
│ │ │ ├── oidc.go
│ │ │ └── management.go
│ │ ├── ldap/
│ │ │ ├── client.go
│ │ │ └── attrmap.go
│ │ ├── casbin/ # Enforcer 初始化 helper
│ │ └── uid/
│ │ ├── encode.go
│ │ └── generator.go
│ │
│ ├── model/
│ │ ├── auth/
│ │ │ └── ...
│ │ ├── member/
│ │ │ └── ...
│ │ └── permission/
│ │ ├── entity/
│ │ │ ├── permission.go
│ │ │ ├── role.go
│ │ │ ├── user_role.go
│ │ │ ├── role_permission.go
│ │ │ └── role_mapping.go
│ │ ├── enum/
│ │ │ ├── status.go
│ │ │ └── permission_type.go
│ │ ├── repository/
│ │ │ ├── permission.go
│ │ │ ├── role.go
│ │ │ ├── user_role.go
│ │ │ ├── role_permission.go
│ │ │ ├── role_mapping.go
│ │ │ └── casbin_redis_adapter.go # 沿用 permission-server
│ │ ├── usecase/
│ │ │ ├── permission_tree.go # 沿用 permission-server
│ │ │ ├── rbac.go # Casbin LoadPolicy / Check
│ │ │ ├── permission.go
│ │ │ ├── role.go
│ │ │ ├── role_permission.go
│ │ │ ├── user_role.go
│ │ │ ├── role_mapping.go
│ │ │ └── authorization_query.go
│ │ ├── rbac/
│ │ │ └── rule.go # Casbin Rule struct
│ │ ├── config/
│ │ ├── errors.go
│ │ ├── redis.go
│ │ └── mock/
│ │
│ └── worker/
│ ├── directory_sync/
│ └── policy_sync/ # 可選:定時 LoadPolicy
├── etc/
│ ├── gateway.yaml
│ └── rbac.conf # Casbin 模型(沿用 permission-server
└── docs/
├── model.md
└── identity-member-design.md # 本文件
```
---
## 16. 設定檔
`etc/gateway.yaml` 擴充草案:
```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.example.com
ClientID: ${ZITADEL_CLIENT_ID}
JWKSUrl: https://id.example.com/oauth/v2/keys
MgmtURL: https://id.example.com/management/v1
MgmtToken: ${ZITADEL_MGMT_TOKEN}
Mongo:
# 見 internal/library/mongo 設定
Redis:
Host: 127.0.0.1:6379
Type: node
Member:
DefaultLanguage: zh-tw
DefaultCurrency: TWD
Permission:
RBACModelPath: etc/rbac.conf
PolicySyncInterval: 5m
PlatformSuperAdminUID: ${PLATFORM_SUPER_ADMIN_UID} # 對應舊 GodDog bypass
CacheTTLSeconds: 300
```
---
## 17. 實施順序
| 階段 | 內容 | 產出 |
|------|------|------|
| **P0** | 目錄骨架、entity、redis key、config | 可啟動、可連 Mongo/Redis |
| **P1** | UID generator + EnsureMember + token exchange | 可登入取得 JWT + 可讀 UID |
| **P2** | JWT middleware + jti 黑名單 + auth_gen + logout/refresh | 完整 Token 生命週期 |
| **P3** | Permission seed + PermissionTree + Casbin RBAC + Redis Adapter | 可 LoadPolicy / Check |
| **P4** | member profile API + 預設 Role seed + CasbinRBACMiddleware | `/members/me` + API 授權生效 |
| **P5** | RolePermission + UserRole + B2B Role CRUD + Permission 勾選 API | 租戶完全自定義 |
| **P6** | Tenant 建立 + ZITADEL CreateOrg + LDAP 設定 | 多租戶 |
| **P7** | Directory Sync WorkerAD + OpenLDAP | 企業目錄同步 |
| **P8** | SCIM 2.0 endpoint + Group 映射 | 企業 provisioning |
| **P9** | 壓測100 萬 seed、sharding、調優 | 上線準備 |
---
## 18. 待決策事項
| # | 議題 | 選項 | 備註 |
|---|------|------|------|
| 1 | UID 格式 | `ACME-ODWXGYBK` vs 純 `ODWXGYBK` | 建議带前缀 |
| 2 | SCIM 路由 | `/scim/v2/tenants/{id}` vs 獨立子域名 | |
| 3 | ZITADEL 部署 | Self-hosted vs Cloud | 影響 LDAP 網路 |
| 4 | 權限變更生效 | 仅清 cache vs 强制 INCR auth_gen | 建议重要變更 INCR |
| 5 | B2C 租戶 | 是否允許自定義 Role | 建议 B2C 只用 seed 模板B2B 完全自定義 |
| 6 | Refresh Token | 是否輪換 + 舊 jti 黑名單 | 建议輪換 |
| 7 | UserRole | 一 user 多 role vs 單 role | 建议多 roleCasbin 逐一 Check |
| 8 | PlatformSuperAdminUID | 固定 uid vs 平台 Role | 建议保留 bypass uid + 后续可移除 |
---
## 附錄 AServiceContext 組裝草案
```go
type ServiceContext struct {
Config config.Config
Validator validate.Validate
// library clients
Zitadel *zitadel.Client
// usecases
AuthUC authusecase.TokenUseCase
MemberProvUC memberusecase.ProvisioningUseCase
MemberProfileUC memberusecase.ProfileUseCase
MemberAdminUC memberusecase.AdminUseCase
TenantUC memberusecase.TenantUseCase
ScimUC memberusecase.ScimUseCase
// permission usecases對齊 permission-server 拆分)
PermRBACUC permusecase.RBACUseCase
PermUC permusecase.PermissionUseCase
RoleUC permusecase.RoleUseCase
RolePermUC permusecase.RolePermissionUseCase
UserRoleUC permusecase.UserRoleUseCase
RoleMappingUC permusecase.RoleMappingUseCase
AuthQueryUC permusecase.AuthorizationQueryUseCase
}
```
---
## 附錄 Cpermission-server 遷移對照(程式碼級)
| permission-server 檔案 | Gateway 目標 | 遷移方式 |
|------------------------|--------------|----------|
| `pkg/usecase/permission_tree.go` | `model/permission/usecase/permission_tree.go` | 幾乎原樣搬移 |
| `pkg/usecase/casbin_redis_rbac.go` | `model/permission/usecase/rbac.go` | 加 `tenant_id` 過濾 |
| `pkg/repository/casbin_redis_adapter.go` | `model/permission/repository/casbin_redis_adapter.go` | 原樣搬移 |
| `pkg/domain/rbac/rule.go` | `model/permission/rbac/rule.go` | 原樣搬移 |
| `etc/rbac.conf` | `etc/rbac.conf` | 原樣搬移 |
| `pkg/usecase/role.go` | `model/permission/usecase/role.go` | `ClientID`→`TenantID` |
| `pkg/usecase/role_permission.go` | `model/permission/usecase/role_permission.go` | 原樣搬移 |
| `pkg/usecase/user_role.go` | `model/permission/usecase/user_role.go` | 改支援多角色 |
| `pkg/usecase/token.go` | **`model/auth/usecase/token.go`** | 不在 permission 模組 |
| `generate/database/seeders/*_permission*` | `generate/database/seeders/` 或 Mongo seed | 改為 Gateway seed job |
---
## 附錄 B與 model.md 的關係
- 本文件:**做什麼**架構、流程、API、權限模型
- [model.md](./model.md)**怎麼寫**entity / repository / usecase 程式碼規範)
實作時兩份文件搭配使用。
---
## 修訂紀錄
| 日期 | 版本 | 說明 |
|------|------|------|
| 2026-05-19 | 0.1.0 | 初稿auth + member + permissionB2B 自定義)+ ZITADEL/LDAP/SCIM |
| 2026-05-19 | 0.2.0 | 對齊 app-cloudep-permission-serverCasbin RBAC、Permission Tree、Role/RolePermission |