# Permission 模組 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 速查 --- ## TL;DR ```mermaid flowchart LR subgraph Platform["平台層"] Catalog[Permission Catalog] end subgraph Tenant["租戶層"] Role[Role] RP[RolePermission] UR[UserRole] RM[RoleMapping] end Catalog -- 勾選 --> RP --> Role Role -- 指派 --> UR Role -- 對應 --> RM Tenant -- LoadPolicy --> Casbin[(Casbin Enforcer
Redis adapter)] Casbin -- Check --> Middleware[CasbinRBAC Middleware] ``` - Permission **平台 seed 全局**(`cmd/permission-seed`);租戶不可新增、只能勾選 - Role / RolePermission / UserRole **租戶獨立**;同名 role 跨租戶共存 - Role.Key 一旦建立 **不可改**(外部 IdP mapping 直接綁 key) - 多 pod 同步:**Redis Pub/Sub 即時 + 5min cron 兜底** --- ## 核心概念 | 概念 | 簡述 | 關鍵欄位 | |------|------|----------| | **Permission** | 平台級權限節點(樹狀,dot notation) | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy | | **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]` | ### 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 permission.role.management ← 分類 ├── permission.role.read GET /api/v1/permissions/roles ├── permission.role.write POST/PUT/DELETE /api/v1/permissions/roles* └── permission.assign.write POST/DELETE /api/v1/permissions/users/*/roles* ``` > 分類節點(無 `http_path`)**不會**寫入 Casbin policy;只是 UI 樹狀渲染 + parent closure 用。 --- ## 目錄結構 ``` internal/model/permission/ ├── README.md ├── config/ # CasbinConfig / CacheConfig / ReloadConfig ├── domain/ │ ├── 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 ``` --- ## 7 個 UseCase | 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 訂閱 | --- ## 資料儲存 ### 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)` | 啟動建索引:`permrepo.EnsureMongoIndexes`(已掛在 `cmd/mongo-index`)。 ### Redis | Key | 內容 | TTL | 由誰寫 | |-----|------|-----|--------| | `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,且 Subscribe 會佔住 conn)。 --- ## 關鍵流程 ### 1. RolePermission 全量取代(PUT /roles/:id/permissions) ```mermaid sequenceDiagram 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) UC-->>API: nil ``` ### 2. SyncFromX(外部 IdP 同步) ```mermaid sequenceDiagram Sync->>Map: GetByExternal(tenant, source, externalKey) Map-->>Sync: RoleMapping(internal_role_key) Note over Sync: 收齊 IdP 端 → keys Sync->>UC: ReplaceForSource(tenant, uid, source, [roleKeys]) UC->>UC: 阻擋 source==manual(防誤洗) UC->>URR: DeleteMany source + BulkInsert UC->>RBAC: BroadcastReload(tenant) ``` ### 3. LoadPolicy + Check ```mermaid sequenceDiagram Trigger->>RBAC: LoadPolicy(tenantID) RBAC->>Roles: ListByTenant RBAC->>RP: ListByRoles(roleIDs) RBAC->>Perms: GetByIDs(unique perm ids) RBAC->>RBAC: 過濾 IsLeaf() && Status=open RBAC->>Enforcer: ClearPolicy + AddPolicies RBAC->>Adapter: SaveAll → Redis pipelined DEL+SADD MW->>RBAC: Check{tenant, uid, path, method} RBAC->>URR: ListByUser → ListByTenantAndIDs(過濾 status=open) loop role in roles(any-allow) RBAC->>Enforcer: EnforceEx end RBAC-->>MW: Allow / Deny(403) ``` ### 4. Pub/Sub 多 Pod Reload ```mermaid sequenceDiagram PodA->>PodA: RolePermission.Replace + LoadPolicy PodA->>Redis: PUBLISH casbin:reload {tenant, ts} Redis-->>PodB: 推 message Redis-->>PodC: 推 message PodB->>PodB: handleReload → LoadPolicy(tenant) PodC->>PodC: handleReload → LoadPolicy(tenant) Note over PodB,PodC: 2-3ms 內三個 pod 同步 ``` > 兜底:每個 pod 可定時 `LoadAllPolicies`(5min cron,建議 svc 層或 cron-worker 觸發;本模組不內建)。 --- ## Casbin 模型(`etc/rbac.conf`) ```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`:支援 `/api/v1/members/*` - `regexMatch`:`GET|POST|PATCH` 多 method 同 policy - 平台 Admin bypass 不寫在 matcher,由 middleware 預檢(保留 audit) --- ## ServiceContext 注入 ```go 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` 決定放行或拒絕。 --- ## HTTP API(`/api/v1/permissions`) | 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 或 `*`) | 完整 schema 見 `generate/api/permission.api`。 --- ## 設定(`etc/gateway.dev.yaml`) ```yaml Permission: Casbin: Enabled: false # 預設關閉 ModelPath: etc/rbac.conf PolicyAdapter: auto # auto / redis / mongo Cache: UserRolesTTLSeconds: 300 RolePermsTTLSeconds: 300 CatalogTTLSeconds: 600 Reload: Channel: casbin:reload DebounceMilliseconds: 200 HeartbeatSeconds: 60 ``` --- ## CLI / 操作 ```bash # 建索引 make mongo-index # Catalog seed(全平台) go run ./cmd/permission-seed -f etc/gateway.dev.yaml # Catalog + 為 tenant seed 5 個 system role go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 # 只 reseed tenant role(catalog 已存在) go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog # 強制全 pod 重載 policy curl -X POST http://localhost:8888/api/v1/permissions/policy/reload \ -H "Authorization: Bearer $TOKEN" \ -d '{"tenant_id": "*"}' ``` 預設 5 個 system role:`tenant_owner` / `tenant_admin` / `member_manager` / `member` / `viewer`(`seed/catalog.go::DefaultSystemRoles`)。 --- ## 設計約束(速查) | 議題 | 決策 | 原因 | |------|------|------| | Permission `name` 改名 | 禁止 | 被 RolePermission / Casbin policy.name 引用;廢棄改 `status=close` | | Role `key` 改名 | 禁止 | 外部 IdP mapping 直接綁 key | | `is_system` 刪除 / 改 status | 拒絕 | 平台預設角色保留 | | `manual` source ReplaceForSource | 拒絕 | 防 SyncFromX 誤洗手動指派 | | 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時 + reboot 不漏 | | Pub/Sub client | 獨立 go-redis | go-zero 沒包 Subscribe | | Catalog 改動 | seed CLI(idempotent) | catalog.json 是 SoT | --- ## 測試 ```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)。