template-monorepo/internal/model/permission/README.md

24 KiB
Raw Blame History

Permission Module

本模組提供 Gateway 多租戶 B2B 自定義 RBAC:平台級 Permission Catalog + 租戶級 Role / RolePermission / UserRole / RoleMapping搭配 Casbin enforcer 進行 HTTP path/method 授權。設計參考 docs/identity-member-design.md §6 / §7.3 / §13。


0. TL;DR

flowchart LR
    subgraph Platform["平台層 (Platform-wide)"]
      Catalog[Permission Catalog]
    end
    subgraph Tenant["租戶層 (per-tenant)"]
      Role[Role]
      RP[RolePermission]
      UR[UserRole]
      RM[RoleMapping]
    end
    Catalog -- 勾選 --> RP --> Role
    Role -- 指派 --> UR
    Role -- 對應 --> RM

    Tenant -- LoadPolicy --> Casbin[(Casbin Enforcer<br/>Redis adapter)]
    Casbin -- Check --> Middleware[CasbinRBAC Middleware]
  • Permission 平台 seed 全局cmd/permission-seed),租戶不可新增;只能勾選。
  • Role / RolePermission / UserRole 租戶獨立;同名 role 可在不同租戶共存。
  • Role.Key 一旦建立 不可改;外部 IdPZITADEL / LDAP / SCIM以 Key 作對應。
  • 多 pod 同步:Redis Pub/Sub 即時通知 + 5min cron 兜底

1. 核心概念

概念 簡述 關鍵欄位
Permission 平台級權限節點樹狀dot notation name 唯一、http_methods + http_path 命中 Casbin policy
Role 租戶內的角色 tenant_id + key uniqueis_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)

1.1 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

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 用。


2. 目錄結構

internal/model/permission/
├── README.md                       # 本文件
├── config/
│   └── config.go                   # 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
└── seed/
    ├── catalog.go                  # embed + Apply + DefaultSystemRoles
    └── catalog.json                # 平台 seed 資料

3. 模組依賴

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]

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

5. 資料儲存

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

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

Redis Set + JSON 編碼是為了讓 SaveAll 用 pipelined DEL + SADD 一致性更新Pub/Sub 走獨立 go-redis clientgo-zero 沒有 Subscribe詳見 internal/library/redis/pubsub.go


6. 核心流程時序圖

6.1 NewModuleFromParam — 模組組裝

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 = nilCheck 永遠 deny
    end
    Mod->>Mod: New {Permission, Role, RolePermission, UserRole, RoleMapping, AuthorizationQuery}
    Mod-->>Boot: *Module7 usecases + 5 repos

6.2 Permission Catalog Seed

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 roletenant_owner / tenant_admin / member_manager / member / viewer,定義於 seed/catalog.go::DefaultSystemRoles

6.3 Role 建立 / 更新 / 刪除

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

sequenceDiagram
    participant API as PUT /permissions/roles/:id/permissions
    participant Logic as logic.replaceRolePermissions
    participant UC as RolePermissionUseCase
    participant Roles as RoleRepository
    participant Perms as PermissionRepository
    participant RP as RolePermissionRepository
    participant RBAC as RBACUseCase

    API->>Logic: req{ID, PermissionIDs}
    Logic->>UC: Replace(tenantID, roleID, ids)
    UC->>Roles: GetByID驗證 tenant 一致)
    UC->>Perms: GetAll拿到 catalog 全表)
    UC->>UC: 檢查 ids ⊆ catalog
    UC->>UC: getFullParentPermissionIDs(ids, all)
    UC->>RP: SetForRole(tenantID, roleID, closure)
    Note over RP: DeleteMany + InsertMany 原子化
    UC->>RBAC: BroadcastReload(tenantID)
    RBAC-->>UC: okfire-and-forget
    UC-->>Logic: nil
    Logic-->>API: 200 OK

6.5 UserRole 指派 / 撤銷

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 來源同步)

sequenceDiagram
    participant Sync as auth/provisioning
    participant UC as UserRoleUseCase
    participant Map as RoleMappingUseCase
    participant Roles as RoleRepository
    participant URR as UserRoleRepository
    participant RBAC as RBACUseCase

    Sync->>Map: GetByExternal(tenant, source=zitadel, externalKey)
    Map-->>Sync: RoleMapping(internal_role_key)
    Note over Sync: 收齊 IdP 端所有 roles → keys
    Sync->>UC: ReplaceForSource(tenant, uid, source=zitadel, [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<br/>※ source=manual 紀錄不動
    UC->>RBAC: BroadcastReload(tenant)

6.7 LoadPolicyCasbin 規則載入)

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->>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

6.8 Check授權檢查

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
    loop role in rolesany-allow
        RBAC->>Enf: EnforceEx(tenant, role.key, path, method)
        alt allow
            RBAC-->>MW: CheckResult{Allow=true, MatchedRoleKey, MatchedPolicyRow}
        end
    end
    MW->>MW: result.Allow ? next : 403 (errs.AuthForbidden)

6.9 Pub/Sub 多 Pod Reload

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->>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 可定時跑 LoadAllPolicies5min cron未在本模組內排程建議 svc 層或 cron-worker 觸發)。掃 Redis permission:casbin:rules:* key 推導 tenant 列表。

6.10 GET /permissions/me前端選單渲染

sequenceDiagram
    participant Front as Frontend
    participant API as GET /permissions/me
    participant UC as AuthorizationQueryUseCase
    participant URR as UserRoleRepository
    participant Roles as RoleRepository
    participant RP as RolePermissionRepository
    participant Perms as PermissionRepository

    Front->>API: Bearer JWT
    API->>UC: Me(tenant, uid, includeTree)
    UC->>URR: ListByUser
    UC->>Roles: ListByTenantAndIDs過濾 status=open
    UC->>RP: ListByRoles(roleIDs)
    UC->>Perms: GetByIDs(unique perm ids)
    UC->>UC: permission map = name→status
    alt includeTree
        UC->>UC: buildPermissionTree + filterOpenNodes
    end
    UC-->>API: { uid, tenant_id, roles, permissions, tree? }
    API-->>Front: 200 OK

7. Casbin 模型(etc/rbac.conf

[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/* 萬用 path
  • regexMatchGET|POST|PATCH 多 method 同一 policy
  • 平台 Admin bypass 不寫進 matcher由 middleware 預檢(保留 audit

8. ServiceContext 注入

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 enforcerMongo+Redis 全到位才有)
sc.PermissionRoleRepo       // 給 SCIM / SyncFromX 等下游使用

未啟用 Casbin 時 PermissionRBAC == nilCheck() 永遠 denymiddleware 會拒絕所有請求(除非 AllowMissingActor=true)。


9. HTTP API前綴 /api/v1/permissions

Method Path Handler 說明
GET /catalog getPermissionCatalog 全局 Catalogtree=true 取樹狀)
GET /me getMePermissions 當前 user 的 role / permission map
GET /roles listRoles 租戶角色清單
POST /roles createRole 建立角色key 不可改)
PATCH /roles/:id updateRole 更新 display_name / statussystem 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 強制重載(單租戶或 *

完整錯誤碼註解參見 generate/api/permission.api,由 make gen-doc 出 OpenAPI。


10. 設定範例(etc/gateway.dev.example.yaml

Permission:
  Casbin:
    Enabled: false                 # 預設關閉,啟用後 RBAC enforcement 生效
    ModelPath: etc/rbac.conf
    PolicyAdapter: auto             # auto / redis / mongo
  Cache:
    UserRolesTTLSeconds: 300
    RolePermsTTLSeconds: 300
    CatalogTTLSeconds: 600
  Reload:
    Channel: casbin:reload
    DebounceMilliseconds: 200
    HeartbeatSeconds: 60

11. CLI / 操作指南

# 1) 建索引
make mongo-index

# 2) 撰寫 / 修改 catalog
$EDITOR internal/model/permission/seed/catalog.json

# 3) 全平台 seed catalog不為任何 tenant 建 role
go run ./cmd/permission-seed -f etc/gateway.dev.yaml

# 4) 同時為 dev 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 rolecatalog 已存在)
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog

# 7) 強制全部 pod 重載 policyHTTP
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" \
  -d '{"tenant_id": "*"}'

12. 中介層middleware/casbin_rbac.go

現況: middleware 已寫好,但 尚未掛入 routes.go(避免影響現有 dev 模式)。要啟用:

import perm "gateway/internal/middleware"

server.AddRoutes(routes,
    rest.WithMiddlewares(
        []rest.Middleware{
            middleware.CloudEPJWT(serverCtx.AuthToken),  // 已存在
            middleware.CasbinRBAC(serverCtx.PermissionRBAC, middleware.CasbinRBACOptions{
                AllowMissingActor: false,
                SkipPaths: map[string]struct{}{
                    "/api/v1/health": {},
                },
            }),
        }...,
    ),
    rest.WithPrefix("/api/v1/members"),
)

要先:

  1. 跑 seed CLI 把 catalog + system role 建好
  2. 為平台 admin tenant 建 platform_super_admin role + bypass allowlist
  3. 開啟 Permission.Casbin.Enabled = true
  4. 設好 Permission.Reload.Channel(多 pod 才需要)

13. 測試

# 全模組 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 拒絕 維持平台預期行為
manual source ReplaceForSource 拒絕 防 SyncFromX 誤洗手動指派
Permission 有 * 萬用 path 不建議裸 *;至少帶資源根 防 keyMatch2 貪婪命中跨資源
Casbin 多 enforcer 一 tenant 一個 enforcerlazy 建 比一個 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 端不直接改 catalogseed JSON 是 SoT

15. 後續工作

項目 預估
Platform admin allowlist + audit log 後續
RoleMapping 用 SyncFromX 落地Zitadel / LDAP / SCIM 隨對應 SyncFromX usecase 推進
Policy reload cron worker5 min 取自 svc 啟動 ticker
Role permission 編輯 UI不在 Gateway 內,由前端取資) 前端
細粒度欄位過濾(.plain_code 變體) logic 層額外查 sub-permission