diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go
index 518fb64..f9df59a 100644
--- a/cmd/mongo-index/main.go
+++ b/cmd/mongo-index/main.go
@@ -12,6 +12,7 @@ import (
authrepo "gateway/internal/model/auth/repository"
memberrepo "gateway/internal/model/member/repository"
notifrepo "gateway/internal/model/notification/repository"
+ permrepo "gateway/internal/model/permission/repository"
"github.com/zeromicro/go-zero/core/conf"
)
@@ -52,7 +53,10 @@ func run() error {
if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: auth: %w", err)
}
+ if err := permrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
+ return fmt.Errorf("mongo-index: permission: %w", err)
+ }
- fmt.Println("mongo-index: notifications + notification_dlq + member + auth indexes OK")
+ fmt.Println("mongo-index: notifications + notification_dlq + member + auth + permission indexes OK")
return nil
}
diff --git a/cmd/permission-seed/main.go b/cmd/permission-seed/main.go
new file mode 100644
index 0000000..88946eb
--- /dev/null
+++ b/cmd/permission-seed/main.go
@@ -0,0 +1,89 @@
+// Command permission-seed upserts the platform-wide permission catalog
+// and (optionally) seeds default system roles for one or more tenants.
+//
+// Usage:
+//
+// permission-seed -f etc/gateway.dev.yaml # catalog only
+// permission-seed -f etc/gateway.dev.yaml -tenant TEN-001 # catalog + tenant roles
+// permission-seed -f etc/gateway.dev.yaml -tenant t1,t2 -skip-catalog
+//
+// The seeder is idempotent: re-running only updates fields that changed
+// in the embedded catalog. Default system roles (tenant_owner, etc.)
+// always have is_system=true; their permission set is rewritten on each
+// run so renaming a catalog entry propagates automatically.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "gateway/internal/config"
+ permrepo "gateway/internal/model/permission/repository"
+ permseed "gateway/internal/model/permission/seed"
+
+ "github.com/zeromicro/go-zero/core/conf"
+)
+
+var (
+ configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
+ tenantList = flag.String("tenant", "", "comma-separated tenant IDs to seed default system roles into")
+ skipCatalog = flag.Bool("skip-catalog", false, "skip platform-wide catalog upsert (only seed tenant roles)")
+)
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ flag.Parse()
+
+ var c config.Config
+ conf.MustLoad(*configFile, &c)
+ if c.Mongo.Host == "" {
+ return fmt.Errorf("permission-seed: Mongo.Host is empty in config")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ if err := permrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
+ return fmt.Errorf("permission-seed: ensure indexes: %w", err)
+ }
+
+ perms := permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: &c.Mongo})
+ roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: &c.Mongo})
+ rolePerms := permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: &c.Mongo})
+
+ tenantIDs := splitTenantIDs(*tenantList)
+
+ report, err := permseed.Apply(ctx, perms, roles, rolePerms, permseed.ApplyOptions{
+ TenantIDs: tenantIDs,
+ SkipCatalog: *skipCatalog,
+ })
+ if err != nil {
+ return fmt.Errorf("permission-seed: apply: %w", err)
+ }
+
+ fmt.Printf("permission-seed: catalog upserted=%d roles upserted=%d role-permission rows=%d tenants=%v\n",
+ report.CatalogUpserted, report.RolesUpserted, report.RolePermissionSet, tenantIDs)
+ return nil
+}
+
+func splitTenantIDs(raw string) []string {
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
diff --git a/etc/gateway.dev.example.yaml b/etc/gateway.dev.example.yaml
index 65d04be..33f088a 100644
--- a/etc/gateway.dev.example.yaml
+++ b/etc/gateway.dev.example.yaml
@@ -91,6 +91,20 @@ Auth:
RefreshSecret: "dev-refresh-secret-32-bytes-min!"
RegistrationSessionTTLSeconds: 600
+Permission:
+ Casbin:
+ Enabled: false # 預設關閉;要啟用 RBAC enforcement 時改 true
+ ModelPath: etc/rbac.conf
+ PolicyAdapter: auto # auto / redis / mongo
+ Cache:
+ UserRolesTTLSeconds: 300
+ RolePermsTTLSeconds: 300
+ CatalogTTLSeconds: 600
+ Reload:
+ Channel: casbin:reload
+ DebounceMilliseconds: 200
+ HeartbeatSeconds: 60
+
# ZITADEL identity backend (auth register/login — PR 1+)
# ServiceUserToken: export ZITADEL_SERVICE_TOKEN=...
# OAuthClientSecret: export ZITADEL_OAUTH_CLIENT_SECRET=...
diff --git a/etc/rbac.conf b/etc/rbac.conf
new file mode 100644
index 0000000..c7e0e29
--- /dev/null
+++ b/etc/rbac.conf
@@ -0,0 +1,25 @@
+# Casbin model for the Gateway permission module.
+#
+# Multi-tenant RBAC with HTTP path/method matching. The 5th policy column
+# (name) is the permission.name (dot notation) so audit logs can attribute
+# the matched permission without re-querying the catalog.
+#
+# Request: (tenant, role, path, method)
+# Policy: (tenant, role, path, methods, name)
+# Effect: any role/policy that matches → allow
+# 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.
+
+[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)
diff --git a/generate/api/gateway.api b/generate/api/gateway.api
index aae7b82..230bbc9 100644
--- a/generate/api/gateway.api
+++ b/generate/api/gateway.api
@@ -1,17 +1,17 @@
syntax = "v1"
info (
- title: "Portal-Api-Gateway (PGW)"
- desc: "Digimon web portal API gateway"
- author: "daniel Wang"
- email: "igs170911@gmail.com"
- version: "0.0.1"
- host: "127.0.0.1:8888"
- schemes: "http,https"
- consumes: "application/json"
- produces: "application/json"
- useDefinitions: true
- bizCodeEnumDescription: "102000-成功
10101000-參數格式錯誤(Facade)
10104000-缺少必填欄位(Facade)
28101000-參數格式錯誤(Auth)
28104000-缺少必填欄位(Auth)
28201000-資料庫錯誤(Auth)
28301000-資源不存在(Auth)
28303000-資源已存在(Auth)
28309000-資源狀態無效(Auth)
28310000-配額不足(Auth)
28313000-資源鎖定(Auth)
28501000-未授權(Auth)
28505000-禁止存取(Auth)
28601000-系統內部錯誤(Auth)
28604000-請求過於頻繁(Auth)
28605000-功能未配置(Auth)
28802000-第三方服務錯誤(Auth)
29104000-缺少必填欄位(Member)
29201000-資料庫錯誤(Member)
29301000-資源不存在(Member)
29303000-資源已存在(Member)
29309000-資源狀態無效(Member)
29310000-配額不足(Member)
29501000-未授權(Member)
29505000-禁止存取(Member)
29601000-系統內部錯誤(Member)
29604000-請求過於頻繁(Member)
29605000-功能未配置(Member)"
+ title: "Portal-Api-Gateway (PGW)"
+ desc: "Digimon web portal API gateway"
+ author: "daniel Wang"
+ email: "igs170911@gmail.com"
+ version: "0.0.1"
+ host: "127.0.0.1:8888"
+ schemes: "http,https"
+ consumes: "application/json"
+ produces: "application/json"
+ useDefinitions: true
+ bizCodeEnumDescription: "102000-成功
10101000-參數格式錯誤(Facade)
10104000-缺少必填欄位(Facade)
28101000-參數格式錯誤(Auth)
28104000-缺少必填欄位(Auth)
28201000-資料庫錯誤(Auth)
28301000-資源不存在(Auth)
28303000-資源已存在(Auth)
28309000-資源狀態無效(Auth)
28310000-配額不足(Auth)
28313000-資源鎖定(Auth)
28501000-未授權(Auth)
28505000-禁止存取(Auth)
28601000-系統內部錯誤(Auth)
28604000-請求過於頻繁(Auth)
28605000-功能未配置(Auth)
28802000-第三方服務錯誤(Auth)
29104000-缺少必填欄位(Member)
29201000-資料庫錯誤(Member)
29301000-資源不存在(Member)
29303000-資源已存在(Member)
29309000-資源狀態無效(Member)
29310000-配額不足(Member)
29501000-未授權(Member)
29505000-禁止存取(Member)
29601000-系統內部錯誤(Member)
29604000-請求過於頻繁(Member)
29605000-功能未配置(Member)
31101000-參數格式錯誤(Permission)
31201000-資料庫錯誤(Permission)
31301000-資源不存在(Permission)
31303000-資源已存在(Permission)
31309000-資源狀態無效(Permission)
31312000-前置條件失敗(Permission)
31501000-未授權(Permission)
31601000-系統內部錯誤(Permission)
31605000-功能未配置(Permission)"
)
import (
@@ -19,5 +19,6 @@ import (
"common.api"
"member.api"
"normal.api"
+ "permission.api"
)
diff --git a/generate/api/permission.api b/generate/api/permission.api
new file mode 100644
index 0000000..3c07cd6
--- /dev/null
+++ b/generate/api/permission.api
@@ -0,0 +1,526 @@
+syntax = "v1"
+
+type (
+ // ===== Permission catalog =====
+
+ PermissionCatalogQuery {
+ Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
+ Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
+ Tree bool `form:"tree,optional"`
+ }
+
+ PermissionNode {
+ ID string `json:"id"`
+ Parent string `json:"parent,omitempty"`
+ Name string `json:"name"`
+ HTTPMethods string `json:"http_methods,omitempty"`
+ HTTPPath string `json:"http_path,omitempty"`
+ Status string `json:"status"`
+ Type string `json:"type"`
+ Children []PermissionNode `json:"children,omitempty"`
+ }
+
+ PermissionCatalogData {
+ Tree []PermissionNode `json:"tree,omitempty"`
+ List []PermissionNode `json:"list,omitempty"`
+ }
+
+ // ===== Me permissions =====
+
+ MePermissionsQuery {
+ IncludeTree bool `form:"include_tree,optional"`
+ }
+
+ MePermissionsData {
+ UID string `json:"uid"`
+ TenantID string `json:"tenant_id"`
+ Roles []string `json:"roles"`
+ Permissions map[string]string `json:"permissions"`
+ Tree []PermissionNode `json:"tree,omitempty"`
+ }
+
+ // ===== Roles =====
+
+ RoleData {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ Key string `json:"key"`
+ DisplayName string `json:"display_name"`
+ CreatorUID string `json:"creator_uid,omitempty"`
+ Status string `json:"status"`
+ IsSystem bool `json:"is_system"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ RoleListData {
+ Roles []RoleData `json:"roles"`
+ }
+
+ CreateRoleReq {
+ Key string `json:"key" validate:"required,min=2,max=64"`
+ DisplayName string `json:"display_name,optional"`
+ Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
+ }
+
+ UpdateRoleReq {
+ DisplayName string `json:"display_name,optional"`
+ Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
+ }
+
+ UpdateRoleByIDReq {
+ ID string `path:"id"`
+ DisplayName string `json:"display_name,optional"`
+ Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
+ }
+
+ DeleteRoleByIDReq {
+ ID string `path:"id"`
+ }
+
+ GetRolePermissionsByIDReq {
+ ID string `path:"id"`
+ }
+
+ ReplaceRolePermissionsByIDReq {
+ ID string `path:"id"`
+ PermissionIDs []string `json:"permission_ids"`
+ }
+
+ ListUserRolesReq {
+ UID string `path:"uid"`
+ }
+
+ AssignUserRoleByUIDReq {
+ UID string `path:"uid"`
+ RoleID string `json:"role_id" validate:"required"`
+ Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
+ }
+
+ RevokeUserRoleByIDReq {
+ UID string `path:"uid"`
+ RoleID string `path:"role_id"`
+ }
+
+ // ===== Role permissions =====
+
+ RolePermissionsListData {
+ Permissions []PermissionNode `json:"permissions"`
+ }
+
+ ReplaceRolePermissionsReq {
+ PermissionIDs []string `json:"permission_ids"`
+ }
+
+ // ===== User roles =====
+
+ UIDPath {
+ UID string `path:"uid"`
+ }
+
+ UserRoleData {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ UID string `json:"uid"`
+ RoleID string `json:"role_id"`
+ RoleKey string `json:"role_key"`
+ RoleDisplayName string `json:"role_display_name"`
+ Source string `json:"source"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ UserRoleListData {
+ UserRoles []UserRoleData `json:"user_roles"`
+ }
+
+ AssignUserRoleReq {
+ RoleID string `json:"role_id" validate:"required"`
+ Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
+ }
+
+ UserRoleIDPath {
+ UID string `path:"uid"`
+ RoleID string `path:"role_id"`
+ }
+
+ // ===== Role mappings =====
+
+ RoleMappingData {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ ExternalSource string `json:"external_source"`
+ ExternalKey string `json:"external_key"`
+ InternalRoleID string `json:"internal_role_id"`
+ InternalRoleKey string `json:"internal_role_key"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+ }
+
+ RoleMappingListData {
+ Mappings []RoleMappingData `json:"mappings"`
+ Total int64 `json:"total"`
+ Offset int64 `json:"offset"`
+ Limit int64 `json:"limit"`
+ }
+
+ RoleMappingListQuery {
+ Source string `form:"source,optional" validate:"omitempty,oneof=zitadel ldap scim"`
+ Offset int64 `form:"offset,optional"`
+ Limit int64 `form:"limit,optional"`
+ }
+
+ UpsertRoleMappingReq {
+ ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
+ ExternalKey string `json:"external_key" validate:"required"`
+ InternalRoleKey string `json:"internal_role_key" validate:"required"`
+ }
+
+ DeleteRoleMappingReq {
+ ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
+ ExternalKey string `json:"external_key" validate:"required"`
+ }
+
+ // ===== Policy reload =====
+
+ PolicyReloadReq {
+ TenantID string `json:"tenant_id,optional"`
+ }
+
+ PolicyReloadData {
+ Tenant string `json:"tenant"`
+ TS int64 `json:"ts"`
+ }
+
+ // ===== OK envelopes for swagger =====
+
+ PermissionCatalogOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PermissionCatalogData `json:"data"`
+ }
+
+ MePermissionsOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data MePermissionsData `json:"data"`
+ }
+
+ RoleListOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleListData `json:"data"`
+ }
+
+ RoleOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleData `json:"data"`
+ }
+
+ RolePermissionsListOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RolePermissionsListData `json:"data"`
+ }
+
+ UserRoleListOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data UserRoleListData `json:"data"`
+ }
+
+ UserRoleOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data UserRoleData `json:"data"`
+ }
+
+ RoleMappingListOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleMappingListData `json:"data"`
+ }
+
+ RoleMappingOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleMappingData `json:"data"`
+ }
+
+ PolicyReloadOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PolicyReloadData `json:"data"`
+ }
+)
+
+@server(
+ group: permission
+ prefix: /api/v1/permissions
+)
+service gateway {
+ @doc "取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)"
+ /*
+ @respdoc-200 (PermissionCatalogOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ 31601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 31605000: (APIErrorStatus) Permission 模組未配置
+ ) // 未實作
+ */
+ @handler getPermissionCatalog
+ get /catalog (PermissionCatalogQuery) returns (PermissionCatalogData)
+
+ @doc "取得當前使用者的 role / permission map(前端渲染選單)"
+ /*
+ @respdoc-200 (MePermissionsOKStatus) // 成功(code=102000)
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
+ ) // 未授權
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ 31601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 31605000: (APIErrorStatus) Permission 模組未配置
+ ) // 未實作
+ */
+ @handler getMePermissions
+ get /me (MePermissionsQuery) returns (MePermissionsData)
+
+ @doc "列出租戶內所有角色(含 system role)"
+ /*
+ @respdoc-200 (RoleListOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler listRoles
+ get /roles returns (RoleListData)
+
+ @doc "建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)"
+ /*
+ @respdoc-200 (RoleOKStatus) // 成功
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 31101000: (APIErrorStatus) role key 格式或保留字錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-409 (
+ 31303000: (APIErrorStatus) 同名 role 已存在
+ ) // 衝突
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler createRole
+ post /roles (CreateRoleReq) returns (RoleData)
+
+ @doc "更新角色(display_name / status;is_system 角色不可改 status)"
+ /*
+ @respdoc-200 (RoleOKStatus) // 成功
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) role 不存在
+ ) // 不存在
+ @respdoc-409 (
+ 31309000: (APIErrorStatus) 系統角色無法更新此欄位
+ ) // 衝突
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler updateRole
+ patch /roles/:id (UpdateRoleByIDReq) returns (RoleData)
+
+ @doc "刪除角色(is_system 不可刪;存在 user 指派時拒絕)"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) role 不存在
+ ) // 不存在
+ @respdoc-409 (
+ 31309000: (APIErrorStatus) 系統角色無法刪除
+ 31312000: (APIErrorStatus) 角色仍有使用者指派
+ ) // 衝突
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler deleteRole
+ delete /roles/:id (DeleteRoleByIDReq)
+
+ @doc "讀取角色目前勾選的 permission 集合"
+ /*
+ @respdoc-200 (RolePermissionsListOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) role 不存在
+ ) // 不存在
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler getRolePermissions
+ get /roles/:id/permissions (GetRolePermissionsByIDReq) returns (RolePermissionsListData)
+
+ @doc "全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) role 或 permission 不存在
+ ) // 不存在
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ 31601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ */
+ @handler replaceRolePermissions
+ put /roles/:id/permissions (ReplaceRolePermissionsByIDReq)
+
+ @doc "查詢使用者目前指派的角色(含 RoleKey / DisplayName)"
+ /*
+ @respdoc-200 (UserRoleListOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler listUserRoles
+ get /users/:uid/roles (ListUserRolesReq) returns (UserRoleListData)
+
+ @doc "指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)"
+ /*
+ @respdoc-200 (UserRoleOKStatus) // 成功
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) role 不存在
+ ) // 不存在
+ @respdoc-409 (
+ 31303000: (APIErrorStatus) 角色已指派
+ ) // 衝突
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler assignUserRole
+ post /users/:uid/roles (AssignUserRoleByUIDReq) returns (UserRoleData)
+
+ @doc "撤銷使用者的單一角色"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) 指派不存在
+ ) // 不存在
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler revokeUserRole
+ delete /users/:uid/roles/:role_id (RevokeUserRoleByIDReq)
+
+ @doc "列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)"
+ /*
+ @respdoc-200 (RoleMappingListOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler listRoleMappings
+ get /role-mappings (RoleMappingListQuery) returns (RoleMappingListData)
+
+ @doc "Upsert 外部 IdP 群組到內部 role 的映射"
+ /*
+ @respdoc-200 (RoleMappingOKStatus) // 成功
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) 對應 internal role 不存在
+ ) // 不存在
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler upsertRoleMapping
+ put /role-mappings (UpsertRoleMappingReq) returns (RoleMappingData)
+
+ @doc "刪除外部 → 內部 role 映射"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ ) // 參數錯誤
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-404 (
+ 31301000: (APIErrorStatus) 映射不存在
+ ) // 不存在
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ ) // 內部錯誤
+ */
+ @handler deleteRoleMapping
+ delete /role-mappings (DeleteRoleMappingReq)
+
+ @doc "強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)"
+ /*
+ @respdoc-200 (PolicyReloadOKStatus) // 成功
+ @respdoc-401 (
+ 31501000: (APIErrorStatus) 未授權
+ ) // 未授權
+ @respdoc-500 (
+ 31201000: (APIErrorStatus) 資料庫錯誤
+ 31601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 31605000: (APIErrorStatus) Casbin enforcer 未配置
+ ) // 未實作
+ */
+ @handler reloadPolicy
+ post /policy/reload (PolicyReloadReq) returns (PolicyReloadData)
+}
diff --git a/go.mod b/go.mod
index b291921..8cd570e 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,9 @@ require (
github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
+ github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
+ github.com/casbin/casbin/v2 v2.135.0 // indirect
+ github.com/casbin/govaluate v1.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
diff --git a/go.sum b/go.sum
index 0e324d1..1aec5fa 100644
--- a/go.sum
+++ b/go.sum
@@ -14,10 +14,16 @@ github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
+github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
+github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
+github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
+github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -45,6 +51,7 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -170,6 +177,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -198,6 +206,7 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/config/config.go b/internal/config/config.go
index 67cbcf1..96f90de 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -12,6 +12,7 @@ import (
authconfig "gateway/internal/model/auth/config"
memberconfig "gateway/internal/model/member/config"
notifconfig "gateway/internal/model/notification/config"
+ permconfig "gateway/internal/model/permission/config"
)
type Config struct {
@@ -22,4 +23,5 @@ type Config struct {
Zitadel zitadel.Conf `json:",optional"`
Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"`
+ Permission permconfig.Config `json:",optional"`
}
diff --git a/internal/handler/permission/assign_user_role_handler.go b/internal/handler/permission/assign_user_role_handler.go
new file mode 100644
index 0000000..6df8576
--- /dev/null
+++ b/internal/handler/permission/assign_user_role_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)
+func AssignUserRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.AssignUserRoleByUIDReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewAssignUserRoleLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.AssignUserRole(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/context.go b/internal/handler/permission/context.go
new file mode 100644
index 0000000..39477dc
--- /dev/null
+++ b/internal/handler/permission/context.go
@@ -0,0 +1,18 @@
+package permission
+
+import (
+ "context"
+ "net/http"
+
+ logic "gateway/internal/logic/permission"
+)
+
+// actorContext threads (tenant_id, uid) onto the request context. Bearer
+// JWT middleware writes Actor first; dev mode falls back to headers so
+// `make run-local` works without auth.
+func actorContext(ctx context.Context, r *http.Request) context.Context {
+ if _, err := logic.ActorFromContext(ctx); err == nil {
+ return ctx
+ }
+ return logic.WithActor(ctx, r.Header.Get("X-Tenant-ID"), r.Header.Get("X-UID"))
+}
diff --git a/internal/handler/permission/create_role_handler.go b/internal/handler/permission/create_role_handler.go
new file mode 100644
index 0000000..c8df98b
--- /dev/null
+++ b/internal/handler/permission/create_role_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)
+func CreateRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.CreateRoleReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewCreateRoleLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.CreateRole(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/delete_role_handler.go b/internal/handler/permission/delete_role_handler.go
new file mode 100644
index 0000000..050ed99
--- /dev/null
+++ b/internal/handler/permission/delete_role_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 刪除角色(is_system 不可刪;存在 user 指派時拒絕)
+func DeleteRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.DeleteRoleByIDReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewDeleteRoleLogic(actorContext(r.Context(), r), svcCtx)
+ err := l.DeleteRole(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/internal/handler/permission/delete_role_mapping_handler.go b/internal/handler/permission/delete_role_mapping_handler.go
new file mode 100644
index 0000000..aafecf4
--- /dev/null
+++ b/internal/handler/permission/delete_role_mapping_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 刪除外部 → 內部 role 映射
+func DeleteRoleMappingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.DeleteRoleMappingReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewDeleteRoleMappingLogic(actorContext(r.Context(), r), svcCtx)
+ err := l.DeleteRoleMapping(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/internal/handler/permission/get_me_permissions_handler.go b/internal/handler/permission/get_me_permissions_handler.go
new file mode 100644
index 0000000..00619ca
--- /dev/null
+++ b/internal/handler/permission/get_me_permissions_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 取得當前使用者的 role / permission map(前端渲染選單)
+func GetMePermissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.MePermissionsQuery
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewGetMePermissionsLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.GetMePermissions(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/get_permission_catalog_handler.go b/internal/handler/permission/get_permission_catalog_handler.go
new file mode 100644
index 0000000..61c6e5c
--- /dev/null
+++ b/internal/handler/permission/get_permission_catalog_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)
+func GetPermissionCatalogHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PermissionCatalogQuery
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewGetPermissionCatalogLogic(r.Context(), svcCtx)
+ data, err := l.GetPermissionCatalog(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/get_role_permissions_handler.go b/internal/handler/permission/get_role_permissions_handler.go
new file mode 100644
index 0000000..c9a2322
--- /dev/null
+++ b/internal/handler/permission/get_role_permissions_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 讀取角色目前勾選的 permission 集合
+func GetRolePermissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.GetRolePermissionsByIDReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewGetRolePermissionsLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.GetRolePermissions(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/list_role_mappings_handler.go b/internal/handler/permission/list_role_mappings_handler.go
new file mode 100644
index 0000000..9924908
--- /dev/null
+++ b/internal/handler/permission/list_role_mappings_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)
+func ListRoleMappingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RoleMappingListQuery
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewListRoleMappingsLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.ListRoleMappings(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/list_roles_handler.go b/internal/handler/permission/list_roles_handler.go
new file mode 100644
index 0000000..bf7b158
--- /dev/null
+++ b/internal/handler/permission/list_roles_handler.go
@@ -0,0 +1,21 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+)
+
+// 列出租戶內所有角色(含 system role)
+func ListRolesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ l := permission.NewListRolesLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.ListRoles()
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/list_user_roles_handler.go b/internal/handler/permission/list_user_roles_handler.go
new file mode 100644
index 0000000..074edc5
--- /dev/null
+++ b/internal/handler/permission/list_user_roles_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 查詢使用者目前指派的角色(含 RoleKey / DisplayName)
+func ListUserRolesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ListUserRolesReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewListUserRolesLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.ListUserRoles(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/reload_policy_handler.go b/internal/handler/permission/reload_policy_handler.go
new file mode 100644
index 0000000..8169621
--- /dev/null
+++ b/internal/handler/permission/reload_policy_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)
+func ReloadPolicyHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PolicyReloadReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewReloadPolicyLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.ReloadPolicy(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/replace_role_permissions_handler.go b/internal/handler/permission/replace_role_permissions_handler.go
new file mode 100644
index 0000000..ed481f2
--- /dev/null
+++ b/internal/handler/permission/replace_role_permissions_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)
+func ReplaceRolePermissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ReplaceRolePermissionsByIDReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewReplaceRolePermissionsLogic(actorContext(r.Context(), r), svcCtx)
+ err := l.ReplaceRolePermissions(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/internal/handler/permission/revoke_user_role_handler.go b/internal/handler/permission/revoke_user_role_handler.go
new file mode 100644
index 0000000..3eb405b
--- /dev/null
+++ b/internal/handler/permission/revoke_user_role_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 撤銷使用者的單一角色
+func RevokeUserRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RevokeUserRoleByIDReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewRevokeUserRoleLogic(actorContext(r.Context(), r), svcCtx)
+ err := l.RevokeUserRole(&req)
+ response.Write(r.Context(), w, nil, err)
+ }
+}
diff --git a/internal/handler/permission/update_role_handler.go b/internal/handler/permission/update_role_handler.go
new file mode 100644
index 0000000..c9ac055
--- /dev/null
+++ b/internal/handler/permission/update_role_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 更新角色(display_name / status;is_system 角色不可改 status)
+func UpdateRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpdateRoleByIDReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewUpdateRoleLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.UpdateRole(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/permission/upsert_role_mapping_handler.go b/internal/handler/permission/upsert_role_mapping_handler.go
new file mode 100644
index 0000000..94509c6
--- /dev/null
+++ b/internal/handler/permission/upsert_role_mapping_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package permission
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/permission"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// Upsert 外部 IdP 群組到內部 role 的映射
+func UpsertRoleMappingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.UpsertRoleMappingReq
+ if err := httpx.Parse(r, &req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+ if err := svcCtx.Validator.ValidateAll(&req); err != nil {
+ response.Write(r.Context(), w, nil, response.WrapRequestError(err))
+ return
+ }
+
+ l := permission.NewUpsertRoleMappingLogic(actorContext(r.Context(), r), svcCtx)
+ data, err := l.UpsertRoleMapping(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/routes.go b/internal/handler/routes.go
index b41c510..82cdbf2 100644
--- a/internal/handler/routes.go
+++ b/internal/handler/routes.go
@@ -10,6 +10,7 @@ import (
auth "gateway/internal/handler/auth"
member "gateway/internal/handler/member"
normal "gateway/internal/handler/normal"
+ permission "gateway/internal/handler/permission"
"gateway/internal/svc"
"github.com/zeromicro/go-zero/rest"
@@ -36,6 +37,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/login/social/start",
Handler: auth.LoginSocialStartHandler(serverCtx),
},
+ {
+ // 登出(撤銷 access JWT 及配對 refresh JWT)
+ Method: http.MethodPost,
+ Path: "/logout",
+ Handler: auth.LogoutHandler(serverCtx),
+ },
{
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
Method: http.MethodPost,
@@ -66,12 +73,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/register/social/start",
Handler: auth.RegisterSocialStartHandler(serverCtx),
},
- {
- // 登出(撤銷 access JWT 及配對 refresh JWT)
- Method: http.MethodPost,
- Path: "/logout",
- Handler: auth.LogoutHandler(serverCtx),
- },
{
// ZITADEL id_token 換 CloudEP JWT(企業 SSO)
Method: http.MethodPost,
@@ -178,4 +179,100 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"),
rest.WithTimeout(3000*time.Millisecond),
)
+
+ server.AddRoutes(
+ []rest.Route{
+ {
+ // 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)
+ Method: http.MethodGet,
+ Path: "/catalog",
+ Handler: permission.GetPermissionCatalogHandler(serverCtx),
+ },
+ {
+ // 取得當前使用者的 role / permission map(前端渲染選單)
+ Method: http.MethodGet,
+ Path: "/me",
+ Handler: permission.GetMePermissionsHandler(serverCtx),
+ },
+ {
+ // 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)
+ Method: http.MethodPost,
+ Path: "/policy/reload",
+ Handler: permission.ReloadPolicyHandler(serverCtx),
+ },
+ {
+ // 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)
+ Method: http.MethodGet,
+ Path: "/role-mappings",
+ Handler: permission.ListRoleMappingsHandler(serverCtx),
+ },
+ {
+ // Upsert 外部 IdP 群組到內部 role 的映射
+ Method: http.MethodPut,
+ Path: "/role-mappings",
+ Handler: permission.UpsertRoleMappingHandler(serverCtx),
+ },
+ {
+ // 刪除外部 → 內部 role 映射
+ Method: http.MethodDelete,
+ Path: "/role-mappings",
+ Handler: permission.DeleteRoleMappingHandler(serverCtx),
+ },
+ {
+ // 列出租戶內所有角色(含 system role)
+ Method: http.MethodGet,
+ Path: "/roles",
+ Handler: permission.ListRolesHandler(serverCtx),
+ },
+ {
+ // 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)
+ Method: http.MethodPost,
+ Path: "/roles",
+ Handler: permission.CreateRoleHandler(serverCtx),
+ },
+ {
+ // 更新角色(display_name / status;is_system 角色不可改 status)
+ Method: http.MethodPatch,
+ Path: "/roles/:id",
+ Handler: permission.UpdateRoleHandler(serverCtx),
+ },
+ {
+ // 刪除角色(is_system 不可刪;存在 user 指派時拒絕)
+ Method: http.MethodDelete,
+ Path: "/roles/:id",
+ Handler: permission.DeleteRoleHandler(serverCtx),
+ },
+ {
+ // 讀取角色目前勾選的 permission 集合
+ Method: http.MethodGet,
+ Path: "/roles/:id/permissions",
+ Handler: permission.GetRolePermissionsHandler(serverCtx),
+ },
+ {
+ // 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)
+ Method: http.MethodPut,
+ Path: "/roles/:id/permissions",
+ Handler: permission.ReplaceRolePermissionsHandler(serverCtx),
+ },
+ {
+ // 查詢使用者目前指派的角色(含 RoleKey / DisplayName)
+ Method: http.MethodGet,
+ Path: "/users/:uid/roles",
+ Handler: permission.ListUserRolesHandler(serverCtx),
+ },
+ {
+ // 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)
+ Method: http.MethodPost,
+ Path: "/users/:uid/roles",
+ Handler: permission.AssignUserRoleHandler(serverCtx),
+ },
+ {
+ // 撤銷使用者的單一角色
+ Method: http.MethodDelete,
+ Path: "/users/:uid/roles/:role_id",
+ Handler: permission.RevokeUserRoleHandler(serverCtx),
+ },
+ },
+ rest.WithPrefix("/api/v1/permissions"),
+ )
}
diff --git a/internal/library/errors/code/types.go b/internal/library/errors/code/types.go
index 71f438c..bf137ed 100644
--- a/internal/library/errors/code/types.go
+++ b/internal/library/errors/code/types.go
@@ -140,4 +140,5 @@ const (
Auth Scope = 28
Member Scope = 29
Notification Scope = 30
+ Permission Scope = 31
)
diff --git a/internal/library/redis/client.go b/internal/library/redis/client.go
index 1d84a08..9aac90b 100644
--- a/internal/library/redis/client.go
+++ b/internal/library/redis/client.go
@@ -11,6 +11,7 @@ import (
// Client wraps go-zero Redis so all modules share the same connection pool.
type Client struct {
r *redis.Redis
+ pubSubFields
}
// NewClient returns a shared Redis client, or (nil, nil) when Host is empty.
diff --git a/internal/library/redis/pubsub.go b/internal/library/redis/pubsub.go
new file mode 100644
index 0000000..9f55d58
--- /dev/null
+++ b/internal/library/redis/pubsub.go
@@ -0,0 +1,62 @@
+package redis
+
+import (
+ "sync"
+
+ goredis "github.com/redis/go-redis/v9"
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// PubSubClient returns a lazily constructed *go-redis Client used for
+// Pub/Sub subscriptions. go-zero's wrapper does not expose Subscribe, so
+// permission/notification modules that need Pub/Sub call this helper.
+//
+// The connection is independent from the go-zero connection pool because
+// Subscribe holds the connection for the subscription lifetime; mixing
+// them with the go-zero pool's command path would break the pool.
+func (c *Client) PubSubClient() *goredis.Client {
+ if c == nil || c.r == nil {
+ return nil
+ }
+ c.psMu.Lock()
+ defer c.psMu.Unlock()
+ if c.ps != nil {
+ return c.ps
+ }
+ addr := c.r.Addr
+ user := c.r.User
+ pass := c.r.Pass
+ c.ps = goredis.NewClient(&goredis.Options{
+ Addr: addr,
+ Username: user,
+ Password: pass,
+ })
+ return c.ps
+}
+
+// ClosePubSub closes the lazy-loaded Pub/Sub client (if any). Safe to call
+// even when never opened.
+func (c *Client) ClosePubSub() error {
+ if c == nil {
+ return nil
+ }
+ c.psMu.Lock()
+ defer c.psMu.Unlock()
+ if c.ps == nil {
+ return nil
+ }
+ err := c.ps.Close()
+ c.ps = nil
+ return err
+}
+
+// pubSubFields are the lazy state for PubSub. Lives here so client.go
+// stays minimal; the struct is augmented via the embedded fields below.
+type pubSubFields struct {
+ psMu sync.Mutex
+ ps *goredis.Client
+}
+
+// reference to redis.Redis so the package compiles when only pubsub.go is
+// edited. The actual struct definition is in client.go.
+var _ = (*redis.Redis)(nil)
diff --git a/internal/logic/permission/actor.go b/internal/logic/permission/actor.go
new file mode 100644
index 0000000..f38fac3
--- /dev/null
+++ b/internal/logic/permission/actor.go
@@ -0,0 +1,32 @@
+package permission
+
+import (
+ "context"
+ "fmt"
+)
+
+type actorKey struct{}
+
+// Actor identifies the calling tenant member (Bearer JWT or dev headers).
+// Permission logic always needs (tenant_id, uid) so the auth → permission
+// boundary is explicit; pulling from headers is dev-only.
+type Actor struct {
+ TenantID string
+ UID string
+}
+
+// WithActor stores tenant/uid on the context for permission logic
+// handlers.
+func WithActor(ctx context.Context, tenantID, uid string) context.Context {
+ return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid})
+}
+
+// ActorFromContext reads the actor injected by JWT middleware or dev
+// headers.
+func ActorFromContext(ctx context.Context) (Actor, error) {
+ v, ok := ctx.Value(actorKey{}).(Actor)
+ if !ok || v.TenantID == "" || v.UID == "" {
+ return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers")
+ }
+ return v, nil
+}
diff --git a/internal/logic/permission/assign_user_role_logic.go b/internal/logic/permission/assign_user_role_logic.go
new file mode 100644
index 0000000..34576ca
--- /dev/null
+++ b/internal/logic/permission/assign_user_role_logic.go
@@ -0,0 +1,66 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type AssignUserRoleLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewAssignUserRoleLogic returns the assign-role logic.
+func NewAssignUserRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AssignUserRoleLogic {
+ return &AssignUserRoleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// AssignUserRole assigns the requested role to the path-bound UID.
+func (l *AssignUserRoleLogic) AssignUserRole(req *types.AssignUserRoleByUIDReq) (*types.UserRoleData, error) {
+ if l.svcCtx.PermissionUserRole == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ source := enum.RoleSourceManual
+ if req.Source != "" {
+ source = enum.RoleSource(req.Source)
+ }
+ ur, err := l.svcCtx.PermissionUserRole.Assign(l.ctx, &domperm.AssignParam{
+ TenantID: actor.TenantID,
+ UID: req.UID,
+ RoleID: req.RoleID,
+ Source: source,
+ })
+ if err != nil {
+ return nil, err
+ }
+ role, err := l.svcCtx.PermissionRole.Get(l.ctx, actor.TenantID, ur.RoleID)
+ if err != nil {
+ return nil, err
+ }
+ return &types.UserRoleData{
+ ID: ur.ID.Hex(),
+ TenantID: ur.TenantID,
+ UID: ur.UID,
+ RoleID: ur.RoleID,
+ RoleKey: role.Key,
+ RoleDisplayName: role.DisplayName,
+ Source: ur.Source.String(),
+ CreateAt: ur.CreateAt,
+ UpdateAt: ur.UpdateAt,
+ }, nil
+}
diff --git a/internal/logic/permission/create_role_logic.go b/internal/logic/permission/create_role_logic.go
new file mode 100644
index 0000000..3746902
--- /dev/null
+++ b/internal/logic/permission/create_role_logic.go
@@ -0,0 +1,59 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type CreateRoleLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewCreateRoleLogic returns the create-role logic.
+func NewCreateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateRoleLogic {
+ return &CreateRoleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// CreateRole inserts a new tenant-scoped role.
+func (l *CreateRoleLogic) CreateRole(req *types.CreateRoleReq) (*types.RoleData, error) {
+ if l.svcCtx.PermissionRole == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ role, err := l.svcCtx.PermissionRole.Create(l.ctx, &domperm.CreateRoleParam{
+ TenantID: actor.TenantID,
+ Key: req.Key,
+ DisplayName: req.DisplayName,
+ CreatorUID: actor.UID,
+ Status: enum.Status(req.Status),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.RoleData{
+ ID: role.ID.Hex(),
+ TenantID: role.TenantID,
+ Key: role.Key,
+ DisplayName: role.DisplayName,
+ CreatorUID: role.CreatorUID,
+ Status: role.Status.String(),
+ IsSystem: role.IsSystem,
+ CreateAt: role.CreateAt,
+ UpdateAt: role.UpdateAt,
+ }, nil
+}
diff --git a/internal/logic/permission/delete_role_logic.go b/internal/logic/permission/delete_role_logic.go
new file mode 100644
index 0000000..0be6b22
--- /dev/null
+++ b/internal/logic/permission/delete_role_logic.go
@@ -0,0 +1,38 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type DeleteRoleLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewDeleteRoleLogic returns the role deleter.
+func NewDeleteRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteRoleLogic {
+ return &DeleteRoleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// DeleteRole removes a role; is_system roles refuse, as do roles with
+// active assignments.
+func (l *DeleteRoleLogic) DeleteRole(req *types.DeleteRoleByIDReq) error {
+ if l.svcCtx.PermissionRole == nil {
+ return errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ return l.svcCtx.PermissionRole.Delete(l.ctx, actor.TenantID, req.ID)
+}
diff --git a/internal/logic/permission/delete_role_mapping_logic.go b/internal/logic/permission/delete_role_mapping_logic.go
new file mode 100644
index 0000000..176105d
--- /dev/null
+++ b/internal/logic/permission/delete_role_mapping_logic.go
@@ -0,0 +1,43 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type DeleteRoleMappingLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewDeleteRoleMappingLogic returns the delete-mapping logic.
+func NewDeleteRoleMappingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteRoleMappingLogic {
+ return &DeleteRoleMappingLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// DeleteRoleMapping removes an external→internal mapping by external key.
+func (l *DeleteRoleMappingLogic) DeleteRoleMapping(req *types.DeleteRoleMappingReq) error {
+ if l.svcCtx.PermissionRoleMapping == nil {
+ return errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ return l.svcCtx.PermissionRoleMapping.Delete(
+ l.ctx,
+ actor.TenantID,
+ enum.RoleSource(req.ExternalSource),
+ req.ExternalKey,
+ )
+}
diff --git a/internal/logic/permission/errors.go b/internal/logic/permission/errors.go
new file mode 100644
index 0000000..a5f6fb9
--- /dev/null
+++ b/internal/logic/permission/errors.go
@@ -0,0 +1,8 @@
+package permission
+
+import (
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+)
+
+var errb = errs.For(code.Permission)
diff --git a/internal/logic/permission/get_me_permissions_logic.go b/internal/logic/permission/get_me_permissions_logic.go
new file mode 100644
index 0000000..5f73ecb
--- /dev/null
+++ b/internal/logic/permission/get_me_permissions_logic.go
@@ -0,0 +1,53 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetMePermissionsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewGetMePermissionsLogic returns the "what can I see" reader.
+func NewGetMePermissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMePermissionsLogic {
+ return &GetMePermissionsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// GetMePermissions returns the role + permission map for the current user.
+func (l *GetMePermissionsLogic) GetMePermissions(req *types.MePermissionsQuery) (*types.MePermissionsData, error) {
+ if l.svcCtx.PermissionAuthQuery == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ resp, err := l.svcCtx.PermissionAuthQuery.Me(l.ctx, actor.TenantID, actor.UID, req.IncludeTree)
+ if err != nil {
+ return nil, err
+ }
+ out := &types.MePermissionsData{
+ UID: resp.UID,
+ TenantID: resp.TenantID,
+ Roles: resp.Roles,
+ Permissions: make(map[string]string, len(resp.Permissions)),
+ }
+ for name, status := range resp.Permissions {
+ out.Permissions[name] = status.String()
+ }
+ if req.IncludeTree {
+ out.Tree = mapNodes(resp.Tree)
+ }
+ return out, nil
+}
diff --git a/internal/logic/permission/get_permission_catalog_logic.go b/internal/logic/permission/get_permission_catalog_logic.go
new file mode 100644
index 0000000..ed37859
--- /dev/null
+++ b/internal/logic/permission/get_permission_catalog_logic.go
@@ -0,0 +1,87 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetPermissionCatalogLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewGetPermissionCatalogLogic returns the catalog reader logic.
+func NewGetPermissionCatalogLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPermissionCatalogLogic {
+ return &GetPermissionCatalogLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// GetPermissionCatalog reads the platform-wide catalog (tree + flat list).
+func (l *GetPermissionCatalogLogic) GetPermissionCatalog(req *types.PermissionCatalogQuery) (*types.PermissionCatalogData, error) {
+ if l.svcCtx.PermissionCatalog == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ query := &domperm.CatalogQuery{
+ OnlyOpen: req.Status == string(enum.StatusOpen),
+ }
+ if req.Type != "" {
+ t := enum.PermissionType(req.Type)
+ query.Type = &t
+ }
+ resp := &types.PermissionCatalogData{}
+ if req.Tree {
+ tree, err := l.svcCtx.PermissionCatalog.GetCatalogTree(l.ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ resp.Tree = mapNodes(tree)
+ return resp, nil
+ }
+ list, err := l.svcCtx.PermissionCatalog.List(l.ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ resp.List = make([]types.PermissionNode, 0, len(list))
+ for _, perm := range list {
+ resp.List = append(resp.List, types.PermissionNode{
+ ID: perm.ID.Hex(),
+ Parent: perm.Parent,
+ Name: perm.Name,
+ HTTPMethods: perm.HTTPMethods,
+ HTTPPath: perm.HTTPPath,
+ Status: perm.Status.String(),
+ Type: perm.Type.String(),
+ })
+ }
+ return resp, nil
+}
+
+func mapNodes(nodes []*domperm.PermissionTreeNode) []types.PermissionNode {
+ if len(nodes) == 0 {
+ return nil
+ }
+ out := make([]types.PermissionNode, 0, len(nodes))
+ for _, node := range nodes {
+ out = append(out, types.PermissionNode{
+ ID: node.ID,
+ Parent: node.Parent,
+ Name: node.Name,
+ HTTPMethods: node.HTTPMethods,
+ HTTPPath: node.HTTPPath,
+ Status: node.Status.String(),
+ Type: node.Type.String(),
+ Children: mapNodes(node.Children),
+ })
+ }
+ return out
+}
diff --git a/internal/logic/permission/get_role_permissions_logic.go b/internal/logic/permission/get_role_permissions_logic.go
new file mode 100644
index 0000000..bd113f4
--- /dev/null
+++ b/internal/logic/permission/get_role_permissions_logic.go
@@ -0,0 +1,54 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type GetRolePermissionsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewGetRolePermissionsLogic returns the role-permission reader.
+func NewGetRolePermissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetRolePermissionsLogic {
+ return &GetRolePermissionsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// GetRolePermissions reads the permission catalog entries currently
+// assigned to the role.
+func (l *GetRolePermissionsLogic) GetRolePermissions(req *types.GetRolePermissionsByIDReq) (*types.RolePermissionsListData, error) {
+ if l.svcCtx.PermissionRolePermission == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ perms, err := l.svcCtx.PermissionRolePermission.List(l.ctx, actor.TenantID, req.ID)
+ if err != nil {
+ return nil, err
+ }
+ out := &types.RolePermissionsListData{Permissions: make([]types.PermissionNode, 0, len(perms))}
+ for _, perm := range perms {
+ out.Permissions = append(out.Permissions, types.PermissionNode{
+ ID: perm.ID.Hex(),
+ Parent: perm.Parent,
+ Name: perm.Name,
+ HTTPMethods: perm.HTTPMethods,
+ HTTPPath: perm.HTTPPath,
+ Status: perm.Status.String(),
+ Type: perm.Type.String(),
+ })
+ }
+ return out, nil
+}
diff --git a/internal/logic/permission/list_role_mappings_logic.go b/internal/logic/permission/list_role_mappings_logic.go
new file mode 100644
index 0000000..03ecc47
--- /dev/null
+++ b/internal/logic/permission/list_role_mappings_logic.go
@@ -0,0 +1,69 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListRoleMappingsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewListRoleMappingsLogic returns the role mapping lister.
+func NewListRoleMappingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListRoleMappingsLogic {
+ return &ListRoleMappingsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// ListRoleMappings paginates the external→internal role mapping table.
+func (l *ListRoleMappingsLogic) ListRoleMappings(req *types.RoleMappingListQuery) (*types.RoleMappingListData, error) {
+ if l.svcCtx.PermissionRoleMapping == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ query := &domperm.ListMappingQuery{
+ Offset: req.Offset,
+ Limit: req.Limit,
+ }
+ if req.Source != "" {
+ s := enum.RoleSource(req.Source)
+ query.Source = &s
+ }
+ docs, total, err := l.svcCtx.PermissionRoleMapping.List(l.ctx, actor.TenantID, query)
+ if err != nil {
+ return nil, err
+ }
+ out := &types.RoleMappingListData{
+ Mappings: make([]types.RoleMappingData, 0, len(docs)),
+ Total: total,
+ Offset: req.Offset,
+ Limit: req.Limit,
+ }
+ for _, rm := range docs {
+ out.Mappings = append(out.Mappings, types.RoleMappingData{
+ ID: rm.ID.Hex(),
+ TenantID: rm.TenantID,
+ ExternalSource: rm.ExternalSource.String(),
+ ExternalKey: rm.ExternalKey,
+ InternalRoleID: rm.InternalRoleID,
+ InternalRoleKey: rm.InternalRoleKey,
+ CreateAt: rm.CreateAt,
+ UpdateAt: rm.UpdateAt,
+ })
+ }
+ return out, nil
+}
diff --git a/internal/logic/permission/list_roles_logic.go b/internal/logic/permission/list_roles_logic.go
new file mode 100644
index 0000000..9f2fc06
--- /dev/null
+++ b/internal/logic/permission/list_roles_logic.go
@@ -0,0 +1,55 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListRolesLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewListRolesLogic returns the role lister.
+func NewListRolesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListRolesLogic {
+ return &ListRolesLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// ListRoles lists every role in the caller's tenant (including system roles).
+func (l *ListRolesLogic) ListRoles() (*types.RoleListData, error) {
+ if l.svcCtx.PermissionRole == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ roles, err := l.svcCtx.PermissionRole.List(l.ctx, actor.TenantID)
+ if err != nil {
+ return nil, err
+ }
+ out := &types.RoleListData{Roles: make([]types.RoleData, 0, len(roles))}
+ for _, role := range roles {
+ out.Roles = append(out.Roles, types.RoleData{
+ ID: role.ID.Hex(),
+ TenantID: role.TenantID,
+ Key: role.Key,
+ DisplayName: role.DisplayName,
+ CreatorUID: role.CreatorUID,
+ Status: role.Status.String(),
+ IsSystem: role.IsSystem,
+ CreateAt: role.CreateAt,
+ UpdateAt: role.UpdateAt,
+ })
+ }
+ return out, nil
+}
diff --git a/internal/logic/permission/list_user_roles_logic.go b/internal/logic/permission/list_user_roles_logic.go
new file mode 100644
index 0000000..72f9d00
--- /dev/null
+++ b/internal/logic/permission/list_user_roles_logic.go
@@ -0,0 +1,55 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ListUserRolesLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewListUserRolesLogic returns the user-role lister.
+func NewListUserRolesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListUserRolesLogic {
+ return &ListUserRolesLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// ListUserRoles returns the role assignments for the path-bound UID.
+func (l *ListUserRolesLogic) ListUserRoles(req *types.ListUserRolesReq) (*types.UserRoleListData, error) {
+ if l.svcCtx.PermissionUserRole == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ rows, err := l.svcCtx.PermissionUserRole.List(l.ctx, actor.TenantID, req.UID)
+ if err != nil {
+ return nil, err
+ }
+ out := &types.UserRoleListData{UserRoles: make([]types.UserRoleData, 0, len(rows))}
+ for _, summary := range rows {
+ out.UserRoles = append(out.UserRoles, types.UserRoleData{
+ ID: summary.ID.Hex(),
+ TenantID: summary.TenantID,
+ UID: summary.UID,
+ RoleID: summary.RoleID,
+ RoleKey: summary.RoleKey,
+ RoleDisplayName: summary.RoleDisplayName,
+ Source: summary.Source.String(),
+ CreateAt: summary.CreateAt,
+ UpdateAt: summary.UpdateAt,
+ })
+ }
+ return out, nil
+}
diff --git a/internal/logic/permission/reload_policy_logic.go b/internal/logic/permission/reload_policy_logic.go
new file mode 100644
index 0000000..f44a157
--- /dev/null
+++ b/internal/logic/permission/reload_policy_logic.go
@@ -0,0 +1,57 @@
+package permission
+
+import (
+ "context"
+ "time"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ReloadPolicyLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewReloadPolicyLogic returns the policy reload logic.
+func NewReloadPolicyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReloadPolicyLogic {
+ return &ReloadPolicyLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// ReloadPolicy forces a Casbin LoadPolicy on this pod and broadcasts a
+// Pub/Sub event so other pods follow. Empty tenant_id reloads the
+// caller's tenant; "*" reloads every tenant.
+func (l *ReloadPolicyLogic) ReloadPolicy(req *types.PolicyReloadReq) (*types.PolicyReloadData, error) {
+ if l.svcCtx.PermissionRBAC == nil {
+ return nil, errb.SysNotImplemented("casbin enforcer not configured")
+ }
+ tenant := req.TenantID
+ if tenant == "" {
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ tenant = actor.TenantID
+ }
+ if tenant == "*" {
+ if err := l.svcCtx.PermissionRBAC.LoadAllPolicies(l.ctx); err != nil {
+ return nil, err
+ }
+ } else if err := l.svcCtx.PermissionRBAC.LoadPolicy(l.ctx, tenant); err != nil {
+ return nil, err
+ }
+ if err := l.svcCtx.PermissionRBAC.BroadcastReload(l.ctx, tenant); err != nil {
+ l.Errorf("permission: broadcast reload tenant=%s: %v", tenant, err)
+ }
+ return &types.PolicyReloadData{
+ Tenant: tenant,
+ TS: time.Now().UnixMilli(),
+ }, nil
+}
diff --git a/internal/logic/permission/replace_role_permissions_logic.go b/internal/logic/permission/replace_role_permissions_logic.go
new file mode 100644
index 0000000..c1848d2
--- /dev/null
+++ b/internal/logic/permission/replace_role_permissions_logic.go
@@ -0,0 +1,38 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ReplaceRolePermissionsLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewReplaceRolePermissionsLogic returns the bulk-replace logic.
+func NewReplaceRolePermissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ReplaceRolePermissionsLogic {
+ return &ReplaceRolePermissionsLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// ReplaceRolePermissions atomically rewrites the role's permission set.
+// Parents of every requested leaf are auto-included by the usecase.
+func (l *ReplaceRolePermissionsLogic) ReplaceRolePermissions(req *types.ReplaceRolePermissionsByIDReq) error {
+ if l.svcCtx.PermissionRolePermission == nil {
+ return errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ return l.svcCtx.PermissionRolePermission.Replace(l.ctx, actor.TenantID, req.ID, req.PermissionIDs)
+}
diff --git a/internal/logic/permission/revoke_user_role_logic.go b/internal/logic/permission/revoke_user_role_logic.go
new file mode 100644
index 0000000..d9320d3
--- /dev/null
+++ b/internal/logic/permission/revoke_user_role_logic.go
@@ -0,0 +1,37 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RevokeUserRoleLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewRevokeUserRoleLogic returns the revoke-role logic.
+func NewRevokeUserRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RevokeUserRoleLogic {
+ return &RevokeUserRoleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// RevokeUserRole removes a single role assignment.
+func (l *RevokeUserRoleLogic) RevokeUserRole(req *types.RevokeUserRoleByIDReq) error {
+ if l.svcCtx.PermissionUserRole == nil {
+ return errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ return l.svcCtx.PermissionUserRole.Revoke(l.ctx, actor.TenantID, req.UID, req.RoleID)
+}
diff --git a/internal/logic/permission/update_role_logic.go b/internal/logic/permission/update_role_logic.go
new file mode 100644
index 0000000..a9a50ce
--- /dev/null
+++ b/internal/logic/permission/update_role_logic.go
@@ -0,0 +1,62 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateRoleLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewUpdateRoleLogic returns the role updater.
+func NewUpdateRoleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateRoleLogic {
+ return &UpdateRoleLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// UpdateRole patches DisplayName and/or Status.
+func (l *UpdateRoleLogic) UpdateRole(req *types.UpdateRoleByIDReq) (*types.RoleData, error) {
+ if l.svcCtx.PermissionRole == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ param := &domperm.UpdateRoleParam{}
+ if req.DisplayName != "" {
+ display := req.DisplayName
+ param.DisplayName = &display
+ }
+ if req.Status != "" {
+ status := enum.Status(req.Status)
+ param.Status = &status
+ }
+ role, err := l.svcCtx.PermissionRole.Update(l.ctx, actor.TenantID, req.ID, param)
+ if err != nil {
+ return nil, err
+ }
+ return &types.RoleData{
+ ID: role.ID.Hex(),
+ TenantID: role.TenantID,
+ Key: role.Key,
+ DisplayName: role.DisplayName,
+ CreatorUID: role.CreatorUID,
+ Status: role.Status.String(),
+ IsSystem: role.IsSystem,
+ CreateAt: role.CreateAt,
+ UpdateAt: role.UpdateAt,
+ }, nil
+}
diff --git a/internal/logic/permission/upsert_role_mapping_logic.go b/internal/logic/permission/upsert_role_mapping_logic.go
new file mode 100644
index 0000000..27be233
--- /dev/null
+++ b/internal/logic/permission/upsert_role_mapping_logic.go
@@ -0,0 +1,57 @@
+package permission
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpsertRoleMappingLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// NewUpsertRoleMappingLogic returns the upsert-mapping logic.
+func NewUpsertRoleMappingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertRoleMappingLogic {
+ return &UpsertRoleMappingLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+// UpsertRoleMapping creates or replaces an external→internal mapping.
+func (l *UpsertRoleMappingLogic) UpsertRoleMapping(req *types.UpsertRoleMappingReq) (*types.RoleMappingData, error) {
+ if l.svcCtx.PermissionRoleMapping == nil {
+ return nil, errb.SysNotImplemented("permission module not configured")
+ }
+ actor, err := ActorFromContext(l.ctx)
+ if err != nil {
+ return nil, errb.AuthUnauthorized(err.Error()).WithCause(err)
+ }
+ rm, err := l.svcCtx.PermissionRoleMapping.Upsert(l.ctx, &domperm.UpsertMappingParam{
+ TenantID: actor.TenantID,
+ ExternalSource: enum.RoleSource(req.ExternalSource),
+ ExternalKey: req.ExternalKey,
+ InternalRoleKey: req.InternalRoleKey,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.RoleMappingData{
+ ID: rm.ID.Hex(),
+ TenantID: rm.TenantID,
+ ExternalSource: rm.ExternalSource.String(),
+ ExternalKey: rm.ExternalKey,
+ InternalRoleID: rm.InternalRoleID,
+ InternalRoleKey: rm.InternalRoleKey,
+ CreateAt: rm.CreateAt,
+ UpdateAt: rm.UpdateAt,
+ }, nil
+}
diff --git a/internal/middleware/casbin_rbac.go b/internal/middleware/casbin_rbac.go
new file mode 100644
index 0000000..cadd798
--- /dev/null
+++ b/internal/middleware/casbin_rbac.go
@@ -0,0 +1,92 @@
+package middleware
+
+import (
+ "net/http"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ logicmember "gateway/internal/logic/member"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ "gateway/internal/response"
+
+ "github.com/zeromicro/go-zero/core/logx"
+ "github.com/zeromicro/go-zero/rest"
+)
+
+// CasbinRBACOptions tunes the enforcement middleware.
+type CasbinRBACOptions struct {
+ // PlatformAdminRoleKey short-circuits enforcement when the actor's
+ // auth context flags them as platform admin (handled upstream); the
+ // value is the role key seeded for that role (e.g.
+ // "platform_super_admin"). Empty disables the bypass.
+ PlatformAdminRoleKey string
+
+ // AllowMissingActor lets unauthenticated requests through without
+ // enforcement. Set to true on routes that do their own auth (e.g.
+ // public catalog reads in dev mode).
+ AllowMissingActor bool
+
+ // SkipPaths is an exact-match allowlist (e.g. /api/v1/health). Useful
+ // for opting out specific routes when the middleware is mounted
+ // globally.
+ SkipPaths map[string]struct{}
+}
+
+// CasbinRBAC returns a go-zero middleware that calls
+// rbac.Check(tenant, uid, path, method) and rejects with HTTP 403 when
+// the result is deny.
+//
+// The middleware is intentionally NOT wired into routes.go yet — wiring
+// happens once the platform admin role + audit log pipeline are in
+// place (design §6.7, §8.2). To opt-in, append it to a route group's
+// middleware chain in routes.go.
+func CasbinRBAC(rbac domperm.RBACUseCase, opts CasbinRBACOptions) rest.Middleware {
+ skip := opts.SkipPaths
+ if skip == nil {
+ skip = map[string]struct{}{}
+ }
+ bld := errs.For(code.Permission)
+
+ return func(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if rbac == nil {
+ next(w, r)
+ return
+ }
+ if _, ok := skip[r.URL.Path]; ok {
+ next(w, r)
+ return
+ }
+ actor, err := logicmember.ActorFromContext(r.Context())
+ if err != nil {
+ if opts.AllowMissingActor {
+ next(w, r)
+ return
+ }
+ response.Write(r.Context(), w, nil,
+ bld.AuthUnauthorized("missing actor for rbac check").WithCause(err))
+ return
+ }
+ result, err := rbac.Check(r.Context(), &domperm.CheckRequest{
+ TenantID: actor.TenantID,
+ UID: actor.UID,
+ Path: r.URL.Path,
+ Method: r.Method,
+ })
+ if err != nil {
+ logx.WithContext(r.Context()).Errorf(
+ "casbin: enforce error tenant=%s uid=%s path=%s method=%s: %v",
+ actor.TenantID, actor.UID, r.URL.Path, r.Method, err)
+ response.Write(r.Context(), w, nil,
+ bld.SysInternal("casbin enforce failed").WithCause(err))
+ return
+ }
+ if !result.Allow {
+ response.Write(r.Context(), w, nil,
+ bld.AuthForbidden("rbac denied").WithCause(nil))
+ return
+ }
+ next(w, r)
+ }
+ }
+}
diff --git a/internal/model/member/README.md b/internal/model/member/README.md
index 2c2f9c3..3599d1c 100644
--- a/internal/model/member/README.md
+++ b/internal/model/member/README.md
@@ -1,15 +1,55 @@
-# Member 模組 — OTP / TOTP
+# Member 模組
-Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫):
+Gateway 的會員核心:涵蓋 **Tenant(租戶)**、**Member(會員 profile)**、**Identity(外部身份對映)** 三大實體,以及租戶內 readable UID、業務 email/phone OTP 驗證、TOTP step-up MFA、resend / daily 配額等業務功能。
-| UseCase | 用途 | 典型場景 |
-|---------|------|----------|
-| **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 |
-| **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA |
+> **架構原則**(`docs/model.md` §6.1):
+> usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。
+> 本 module 所有 usecase 都是 **atomic primitives**。
-> **架構原則**:usecase **不可**呼叫其他 usecase。
-> 「產碼 → 寄信/簡訊 → 驗碼 → 更新 profile」這類多步驟流程,由 **logic 層**(或 CLI driver)編排。
-> 詳見 [`docs/model.md`](../../../docs/model.md) §6.1。
+---
+
+## 目錄
+
+- [核心概念](#核心概念)
+- [目錄結構](#目錄結構)
+- [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 雙鍵**:`(tenant_id, uid)` 為對外的可讀主鍵;`zitadel_user_id` 是 OIDC 來源的對映鍵。
+**多租戶等級**:每個 Member 必屬於一個 Tenant,UID 用 `{TenantUIDPrefix}-{Sequence}` 格式(例:`ACME-10000003`)。
+
+### 來源(Origin)
+
+```
+platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified)
+oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC)
+ldap // Directory Sync(Provisioning.EnsureFromLDAP)
+scim // SCIM 2.0(Provisioning.EnsureFromSCIM)
+```
---
@@ -17,321 +57,526 @@ Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫
```
internal/model/member/
-├── config/ # OTP / TOTP 設定
-├── domain/ # 介面、enum、errors、redis key
-│ ├── enum/
-│ ├── repository/
-│ └── usecase/
-├── repository/ # Redis / memory 實作
-├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library)
-├── usecase/ # OTPUseCase、TOTPUseCase 實作
-└── 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/` 純介面 + 常數,**不依賴外部 lib**(除 `bson.ObjectID`)。
+`usecase/` 只依賴 `domain/`。
+`repository/` 依賴 `library/mongo`、`library/redis`。
+
---
-## OTP(One-Time Password)
+## Module 結構與依賴
-### 原理
+```mermaid
+flowchart TB
+ Logic["logic 層
(handler 編排)"]
-1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 Redis,TTL 預設 300 秒。
-2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次;logic 層負責呼叫 `notification.Notifier.Send` 投遞。
-3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt;成功後 **刪除 challenge**(一次性)。
-4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5)即鎖定該 challenge。
+ 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 重發冷卻 + 每日上限 |
+
+---
+
+## 資料儲存
+
+### 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)` |
+
+索引建立由 `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`) | 永久 |
+
+Helper 函式見 `domain/redis.go`,**禁止** 在他處字串拼接 key。
+
+---
+
+## 生命週期與狀態機
+
+```mermaid
+stateDiagram-v2
+ [*] --> unverified: Lifecycle.CreateUnverified
(platform 註冊)
+ [*] --> active: Provisioning.Ensure*
(OIDC/LDAP/SCIM 首登)
+
+ 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
- participant Logic
- participant OTP as OTPUseCase
- participant Redis
- participant Notifier
+ autonumber
+ participant SVC as svc.NewServiceContext
+ participant Mod as member.NewModuleFromParam
+ participant Repo as repository
+ participant Redis
+ participant Mongo
- Logic->>OTP: Generate(tenant, uid, purpose, target)
- OTP->>Redis: Save challenge (bcrypt hash)
- OTP-->>Logic: challenge_id, plainCode
- Logic->>Notifier: Send(code, expires_in)
- Note over Logic: 使用者收到信/簡訊
- Logic->>OTP: Verify(challenge_id, code, uid, purpose)
- OTP->>Redis: Get + bcrypt compare
- OTP->>Redis: Delete challenge
- OTP-->>Logic: target (email/phone)
- Logic->>Logic: Profile.SetBusinessEmailVerified(...)
+ 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 / ...
```
-### Purpose(用途標籤)
-
-```go
-enum.OTPPurposeBusinessEmail // 業務 email 驗證
-enum.OTPPurposeBusinessPhone // 業務 phone 驗證
-enum.OTPPurposeStepUp // step-up(未來 logic 層使用)
-```
-
-Verify 時 `Purpose` 必須與 Generate 一致,否則拒絕。
-
-### API
-
-```go
-// 產碼
-dto, plainCode, err := otpUC.Generate(ctx, &domusecase.GenerateOTPRequest{
- TenantID: "t1",
- UID: "u1",
- Purpose: enum.OTPPurposeBusinessEmail,
- Target: "user@example.com",
-})
-// dto.ChallengeID → 給前端帶回 confirm API
-// plainCode → 只在此刻存在,交給 Notifier 寄出
-
-// 驗碼
-target, err := otpUC.Verify(ctx, &domusecase.VerifyOTPRequest{
- TenantID: "t1",
- UID: "u1",
- ChallengeID: dto.ChallengeID,
- Code: "482913",
- Purpose: enum.OTPPurposeBusinessEmail,
-})
-// 成功 → target == "user@example.com",challenge 已刪除
-
-// 寄送失敗時回滾
-_ = otpUC.Invalidate(ctx, dto.ChallengeID)
-```
-
-### Rate limit(logic 層使用)
-
-`VerifyRateStore` 提供 resend cooldown 與每日上限,**不在 OTPUseCase 內建**:
-
-```go
-// 冷卻(60 秒內不可重發)
-ok, _ := verifyRate.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenant, uid, "email"), 60*time.Second)
-
-// 每日上限(預設 10 次)
-count, _ := verifyRate.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenant, uid, "email"), 24*time.Hour)
-```
-
----
-
-## TOTP(Time-based OTP)
-
-### 原理
-
-遵循 **RFC 6238**,與 Google Authenticator / Authy 相容:
-
-| 參數 | 預設值 |
-|------|--------|
-| 演算法 | HMAC-SHA1 |
-| 週期 | 30 秒 |
-| 位數 | 6 |
-| 時間窗口 | ±1 step(容忍時鐘偏差) |
-
-**儲存安全**:
-
-- Secret 以 **AES-256-GCM** 加密(KEK = `Member.TOTP.SecretKEK`)後寫入 profile。
-- 備援碼以 **bcrypt** 雜湊儲存,明文只在 `ConfirmEnroll` / `RegenerateBackupCodes` 回傳一次。
-- 綁定前的 staged secret 暫存 Redis(`EnrollTTLSeconds`,預設 600 秒)。
-- 驗碼成功後以 Redis 記錄 time step,**同一時間窗口內不可重放**。
+### 2. Tenant 建立
```mermaid
sequenceDiagram
- participant User
- participant Logic
- participant TOTP as TOTPUseCase
- participant Redis
- participant Profile
+ autonumber
+ participant CLI as cmd/member-seed
+ participant TenantUC as TenantUseCase
+ participant Repo as TenantRepository
+ participant Mongo
- Note over Logic,Profile: 綁定階段
- Logic->>TOTP: StartEnroll(tenant, uid, account)
- TOTP->>Redis: Save encrypted staged secret
- TOTP-->>Logic: otpauth_url (QR code)
- User->>User: 掃碼加入 Authenticator
- Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
- TOTP->>Profile: Save encrypted secret + backup hashes
- TOTP->>Redis: Delete staged secret
- TOTP-->>Logic: backup_codes[] (只顯示一次)
-
- Note over Logic,Profile: 日常使用(step-up)
- Logic->>TOTP: VerifyCode(tenant, uid, code)
- TOTP->>Profile: Decrypt secret
- TOTP->>Redis: MarkUsed(timestep) — 防重放
- TOTP-->>Logic: ok / err
+ 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
```
-### API
+### 3. Platform 註冊 (auth + member.Lifecycle)
-```go
-// 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code
-start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com")
-// start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn
+> 屬於 `internal/logic/auth/register_logic.go` 的編排;Member module 只負責 atomic 動作。
-// 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼
-backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913")
-// backupCodes 只回傳這一次,請引導使用者妥善保存
+```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
-// 3. step-up 驗碼 — TOTP 或備援碼皆可
-err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP
-err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪)
+ 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
-// 4. 查狀態
-status, err := totpUC.Status(ctx, "t1", "u1")
-// status.Enrolled, status.BackupCodesRemaining
-
-// 5. 停用 / 重產備援碼(logic 層應先要求 step-up)
-_ = totpUC.Disable(ctx, "t1", "u1")
-newCodes, err := totpUC.RegenerateBackupCodes(ctx, "t1", "u1")
+ 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 簽發)
```
-### VerifyCode 判定順序
+### 4. Provisioning — OIDC / LDAP / SCIM
-1. 長度 = 6 → 當 TOTP 驗(含 ±window)
-2. 通過 → Redis 記錄 time step;已用過則回 `ErrTOTPCodeReplay`
-3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
-4. 皆失敗 → `ErrTOTPInvalidCode`
+外部身份首次登入時透過 `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
-## 設定
-
-`etc/gateway.dev.yaml` → `Member` 區塊:
-
-```yaml
-Member:
- OTP:
- Length: 6 # 驗證碼位數
- TTLSeconds: 300 # challenge 存活時間
- MaxAttempts: 5 # 單 challenge 最大錯誤次數
- ResendCooldownSeconds: 60 # 重發冷卻(logic 層用 VerifyRateStore)
- DailyVerifyLimit: 10 # 每日上限(logic 層用 VerifyRateStore)
- TOTP:
- Issuer: CloudEP
- Algorithm: SHA1
- Digits: 6
- PeriodSeconds: 30
- Window: 1 # ±1 time step
- BackupCodeCount: 10
- BackupCodeLength: 12
- EnrollTTLSeconds: 600 # 綁定 staged secret TTL
- ReplayTTLSeconds: 90 # 重放保護 TTL
- SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空則不啟用 TOTP
+ 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
```
-`SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入(production 建議走 KMS / secret manager)。
+LDAP / SCIM 同樣模式,額外查 `IdentityRepository.GetByExternalID` 處理沒有 zitadel_sub 的情境。
----
+### 5. 業務 Email / Phone OTP 驗證
-## 裝配與注入
+由 `internal/logic/member/verify_helper.go` 編排(`startVerification` + `confirmVerification`),展示 logic 層如何把多個 atomic usecase 串起來。
-### Module factory
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Client
+ participant Logic as logic/member.startVerification
+ participant Rate as VerifyRateUseCase
+ participant OTP as OTPUseCase
+ participant Notif as Notifier
+ participant Profile as ProfileUseCase
+ participant Redis
-```go
-mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
- Redis: rds,
- Config: c.Member,
-})
-// mod.OTP — 永遠有值(需 Redis)
-// mod.TOTP — SecretKEK 有設定時才有值,否則 nil
-// mod.VerifyRate — resend / daily cap
-// mod.Profile — 預設 memory,P4 換 Mongo
+ 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)
+ OTP-->>Logic: challenge_id, plainCode
+ Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in})
+ alt Notifier 失敗
+ Logic->>OTP: Invalidate(challenge_id)
+ Logic-->>Client: 5xx
+ else 成功
+ Logic-->>Client: {challenge_id, expires_in}
+ end
+
+ 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
```
-### ServiceContext
+**關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門。
-Gateway 啟動時(Redis 就緒)自動注入:
+### 6. TOTP 綁定 / Step-up
-```go
-svc.MemberOTP // domusecase.OTPUseCase
-svc.MemberTOTP // domusecase.TOTPUseCase(可能 nil)
-svc.MemberVerifyRate // VerifyRateStore
-svc.MemberProfile // ProfileRepository
+```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)
+
+ 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
+ TOTP->>Enroll: Save(cipherBlob, TTL=600s)
+ TOTP-->>Logic: {otpauth_url, digits=6, period=30}
+ Logic-->>Client: QR code 資料
+
+ Client->>Client: 掃 QR 加入 Authenticator
+ Client->>Logic: POST /me/totp/enroll/confirm {code}
+ Logic->>TOTP: ConfirmEnroll(tenant, uid, 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
+
+ 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
+ end
```
----
+### 7. UID 生成
-## Logic 層編排範例
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Caller as Lifecycle / Provisioning
+ participant Gen as UIDGenerator
+ participant Redis
-以下示範 **verify business email** 完整流程(logic 層職責,尚未有 HTTP handler):
-
-```go
-// ── 發起驗證 ──
-dto, code, err := svc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
- TenantID: tenant, UID: uid,
- Purpose: enum.OTPPurposeBusinessEmail,
- Target: email,
-})
-if err != nil { return err }
-
-_, err = svc.Notifier.Send(ctx, ¬if.SendRequest{
- TenantID: tenant, UID: uid,
- Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail,
- Target: email, Locale: locale,
- Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
- IdempotencyKey: dto.ChallengeID,
-})
-if err != nil {
- _ = svc.MemberOTP.Invalidate(ctx, dto.ChallengeID) // 寄送失敗回滾
- return err
-}
-return dto // 回傳 challenge_id 給前端
-
-// ── 確認驗證 ──
-target, err := svc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{
- TenantID: tenant, UID: uid,
- ChallengeID: req.ChallengeID, Code: req.Code,
- Purpose: enum.OTPPurposeBusinessEmail,
-})
-if err != nil { return err }
-
-return svc.MemberProfile.SetBusinessEmailVerified(ctx, tenant, uid, target)
+ 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
+ end
+ Gen-->>Caller: "{PREFIX}-{seq}" 例:ACME-10000003
```
-`cmd/notify-test` 的 `startMemberVerify` 實作了發起驗證的前半段(Generate + Send),可作為 driver 參考:
-
-```bash
-make deps-up
-make notify-test METHOD=member-email TO=you@example.com
-make notify-test METHOD=member-phone PHONE=0912345678
-```
+`UIDSequenceStart = 10_000_000`(7 位起跳),`UIDPrefix` 限制 2~4 個大寫字母。
---
## Redis Key 命名
-| Key 前綴 | 用途 |
-|----------|------|
-| `member:otp:challenge:{id}` | OTP challenge 狀態 |
-| `member:otp:challenge:{id}:attempts` | 錯誤次數計數 |
-| `member:verify:rate:{tenant}:{uid}:{kind}` | 重發冷卻 |
-| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 |
-| `member:totp:enroll:{tenant}:{uid}` | 綁定 staged secret |
-| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 |
+| 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` |
-Helper 函式見 `domain/redis.go`(`GetOTPChallengeRedisKey` 等)。
+`kind` 通常是 `enum.OTPPurpose` 字串(`business_email`、`business_phone`、`step_up` 等)。
+
+---
+
+## 設定
+
+`etc/gateway.dev.yaml` → `Member` 區塊:
+
+```yaml
+Member:
+ Registration:
+ RequireInviteCode: true # 平台註冊是否強制邀請碼
+ TrustSocialEmailVerified: true # OIDC email_verified=true 時直接 active
+ OTP:
+ Length: 6 # 驗證碼位數
+ TTLSeconds: 300 # challenge 存活時間
+ MaxAttempts: 5 # 單 challenge 最大錯誤次數
+ ResendCooldownSeconds: 60 # 重發冷卻
+ DailyVerifyLimit: 10 # 每日上限
+ TOTP:
+ Issuer: CloudEP
+ Algorithm: SHA1
+ Digits: 6
+ PeriodSeconds: 30
+ Window: 1 # ±1 time step 容忍
+ BackupCodeCount: 10
+ BackupCodeLength: 12
+ EnrollTTLSeconds: 600
+ ReplayTTLSeconds: 90
+ SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空關閉 TOTP
+```
+
+**`SecretKEK`** 可改用環境變數 `TOTP_SECRET_KEK`(prod 建議走 KMS / secret manager)。
+
+---
+
+## 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)
+```
+
+Logic 層使用前務必檢查可能 `nil` 的欄位:
+
+```go
+if sc.MemberTOTP == nil {
+ return errb.SysNotImplemented("member TOTP not configured")
+}
+```
---
## 測試
-### 本機 API(P4)
-
-> JWT / Casbin 尚未接入;dev 模式用 Header 帶身份:
-> `X-Tenant-ID`、`X-UID`
-
-```bash
-make deps-up
-make mongo-index
-make member-seed # 建立 dev tenant + member,輸出 headers
-make run-local # 或 make run
-
-# 範例
-curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
- http://127.0.0.1:8888/api/v1/members/me | jq
-
-# 業務 email 驗證(logic 層:OTP.Generate → Notifier.Send)
-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`(§7.2 對照表)。
-
### 單元測試
```bash
@@ -339,45 +584,48 @@ go test ./internal/model/member/... -v
make check
```
-### 互動式 TOTP(Google Authenticator)
+| 檔案 | 覆蓋 |
+| --- | --- |
+| `usecase/otp_usecase_test.go` | Generate/Verify、UID/purpose mismatch、attempts lock |
+| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
+| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
-本機需 Redis,並在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
+### 本機 API(P4)
+
+```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
+
+# Profile
+curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
+ 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)
```bash
make deps-up
-make totp-test
-```
-
-流程(單一 process,預設 `-step flow`):
-
-1. 終端機印出 **QR code** 與 **Secret key**
-2. 手機 Google Authenticator → 掃描 QR(或手動輸入 Secret)
-3. 輸入 Authenticator 上的 6 碼 → **ConfirmEnroll**(綁定完成,顯示備援碼)
-4. 等 code 刷新後再輸入新 6 碼 → **VerifyCode**(step-up 驗證)
-5. 自動測試重放保護(同一碼再驗應失敗)
-
-進階:
-
-```bash
+make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放
make totp-test STEP=status
make totp-test STEP=disable
-make totp-test STEP=verify CODE=482913
```
-| 檔案 | 覆蓋 |
-|------|------|
-| `usecase/otp_usecase_test.go` | Generate/Verify、UID mismatch、max attempts lock |
-| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
-| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
-| `library/crypto/aesgcm_test.go` | TOTP secret 加解密 |
+需在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
---
-## 尚未實作
+## 設計參考
-- HTTP API / goctl handler(verify-email、verify-phone、totp enroll 等)
-- Logic 層 confirm 流程(Verify + Profile flip + rate limit)
-- `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory)
-- Step-up token 簽發(auth 模組)
-
-設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。
+- 詳細領域模型 / 多租戶設計 / B2B Permission 對接:`docs/identity-member-design.md`
+- 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1
+- 統一錯誤格式(`errb.*`):`internal/library/errors/README.md`
diff --git a/internal/model/permission/README.md b/internal/model/permission/README.md
new file mode 100644
index 0000000..7060125
--- /dev/null
+++ b/internal/model/permission/README.md
@@ -0,0 +1,643 @@
+# 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
+
+```mermaid
+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
Redis adapter)]
+ 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 兜底**。
+
+---
+
+## 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)` |
+
+### 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. 模組依賴
+
+```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]
+```
+
+---
+
+## 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 client(go-zero 沒有 Subscribe),詳見 `internal/library/redis/pubsub.go`。
+
+---
+
+## 6. 核心流程時序圖
+
+### 6.1 NewModuleFromParam — 模組組裝
+
+```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 原子化
+ UC->>RBAC: BroadcastReload(tenantID)
+ RBAC-->>UC: ok(fire-and-forget)
+ UC-->>Logic: nil
+ Logic-->>API: 200 OK
+```
+
+### 6.5 UserRole 指派 / 撤銷
+
+```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)
+ 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
※ source=manual 紀錄不動
+ UC->>RBAC: BroadcastReload(tenant)
+```
+
+### 6.7 LoadPolicy(Casbin 規則載入)
+
+```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->>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(授權檢查)
+
+```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)
+ loop role in roles(any-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
+
+```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->>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 觸發)。掃 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
+```
+
+---
+
+## 7. 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/*` 萬用 path
+- `regexMatch`:`GET|POST|PATCH` 多 method 同一 policy
+- 平台 Admin bypass 不寫進 matcher,由 middleware 預檢(保留 audit)
+
+---
+
+## 8. 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.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用
+```
+
+未啟用 Casbin 時 `PermissionRBAC == nil`,`Check()` 永遠 deny;middleware 會拒絕所有請求(除非 `AllowMissingActor=true`)。
+
+---
+
+## 9. 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` | 強制重載(單租戶或 `*`) |
+
+完整錯誤碼註解參見 `generate/api/permission.api`,由 `make gen-doc` 出 OpenAPI。
+
+---
+
+## 10. 設定範例(`etc/gateway.dev.example.yaml`)
+
+```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 / 操作指南
+
+```bash
+# 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 role(catalog 已存在)
+go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog
+
+# 7) 強制全部 pod 重載 policy(HTTP)
+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 模式)。要啟用:
+
+```go
+import perm "gateway/internal/middleware"
+
+server.AddRoutes(routes,
+ rest.WithMiddlewares(
+ []rest.Middleware{
+ middleware.CloudEPJWT(serverCtx.AuthToken), // 已存在
+ middleware.CasbinRBAC(serverCtx.PermissionRBAC, middleware.CasbinRBACOptions{
+ AllowMissingActor: false,
+ SkipPaths: map[string]struct{}{
+ "/api/v1/health": {},
+ },
+ }),
+ }...,
+ ),
+ rest.WithPrefix("/api/v1/members"),
+)
+```
+
+要先:
+1. 跑 seed CLI 把 catalog + system role 建好
+2. 為平台 admin tenant 建 `platform_super_admin` role + bypass allowlist
+3. 開啟 `Permission.Casbin.Enabled = true`
+4. 設好 `Permission.Reload.Channel`(多 pod 才需要)
+
+---
+
+## 13. 測試
+
+```bash
+# 全模組 unit test
+go test ./internal/model/permission/...
+
+# 含整合(需要 Mongo + Redis 在 docker compose 起著)
+make deps-up
+go test -tags=integration ./internal/model/permission/...
+```
+
+---
+
+## 14. 設計權衡 / 注意事項
+
+| 議題 | 決策 | 原因 |
+|------|------|------|
+| Permission `name` 改名 | **禁止** | 被 RolePermission、UI i18n、Casbin policy.name 引用;廢棄走 `status=close` 然後新建 |
+| Role `key` 改名 | **禁止** | 外部 IdP mapping 直接綁 key;改名會切斷映射 |
+| `is_system` role 刪除 | 拒絕 | 平台預設角色保留 |
+| `is_system` role 改 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 |
+
+---
+
+## 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 |
diff --git a/internal/model/permission/config/config.go b/internal/model/permission/config/config.go
new file mode 100644
index 0000000..bd3a39f
--- /dev/null
+++ b/internal/model/permission/config/config.go
@@ -0,0 +1,75 @@
+package config
+
+// Config tunes the permission module. All fields are optional; Defaults()
+// populates production-safe values.
+type Config struct {
+ // Casbin is the RBAC enforcer config; empty disables enforcement
+ // entirely (Check() returns Allow=true to keep dev mode running).
+ Casbin CasbinConfig `json:",optional"`
+
+ // Cache TTLs for read-side caches.
+ Cache CacheConfig `json:",optional"`
+
+ // Reload tunes the policy reload Pub/Sub subscriber.
+ Reload ReloadConfig `json:",optional"`
+}
+
+// CasbinConfig governs the Casbin enforcer.
+//
+// ModelPath points at etc/rbac.conf (RBAC with domains + keyMatch2 +
+// regexMatch). PolicyAdapter selects redis (default, Pub/Sub friendly) or
+// mongo (read-from-collection on every load).
+type CasbinConfig struct {
+ Enabled bool `json:",optional"`
+ ModelPath string `json:",optional"`
+ PolicyAdapter string `json:",optional,options=redis|mongo|auto"`
+}
+
+// CacheConfig tunes role / permission read caches stored in Redis.
+type CacheConfig struct {
+ UserRolesTTLSeconds int `json:",optional"`
+ RolePermsTTLSeconds int `json:",optional"`
+ CatalogTTLSeconds int `json:",optional"`
+}
+
+// ReloadConfig configures Pub/Sub subscribers used to broadcast policy
+// changes across pods.
+type ReloadConfig struct {
+ Channel string `json:",optional"`
+ DebounceMilliseconds int `json:",optional"`
+ HeartbeatSeconds int `json:",optional"`
+}
+
+// Defaults returns zero-value-safe defaults.
+func (c Config) Defaults() Config {
+ if c.Casbin.ModelPath == "" {
+ c.Casbin.ModelPath = "etc/rbac.conf"
+ }
+ if c.Casbin.PolicyAdapter == "" {
+ c.Casbin.PolicyAdapter = "auto"
+ }
+ if c.Cache.UserRolesTTLSeconds <= 0 {
+ c.Cache.UserRolesTTLSeconds = 300
+ }
+ if c.Cache.RolePermsTTLSeconds <= 0 {
+ c.Cache.RolePermsTTLSeconds = 300
+ }
+ if c.Cache.CatalogTTLSeconds <= 0 {
+ c.Cache.CatalogTTLSeconds = 600
+ }
+ if c.Reload.Channel == "" {
+ c.Reload.Channel = "casbin:reload"
+ }
+ if c.Reload.DebounceMilliseconds <= 0 {
+ c.Reload.DebounceMilliseconds = 200
+ }
+ if c.Reload.HeartbeatSeconds <= 0 {
+ c.Reload.HeartbeatSeconds = 60
+ }
+ return c
+}
+
+// Enabled reports whether the Casbin enforcer should be wired in.
+func (c Config) Enabled() bool {
+ return c.Casbin.Enabled
+}
diff --git a/internal/model/permission/domain/const.go b/internal/model/permission/domain/const.go
new file mode 100644
index 0000000..cfbe3ea
--- /dev/null
+++ b/internal/model/permission/domain/const.go
@@ -0,0 +1,73 @@
+// Package domain holds the permission module's domain-level definitions
+// (entities, enums, repository/usecase interfaces, errors, redis key
+// helpers, BSON field names). Sub-packages MUST NOT depend on the
+// repository or usecase implementation packages.
+package domain
+
+// MongoDB BSON field names used by repositories. Keep in sync with the
+// `bson:` tags on entity structs so usecase / repo code never relies on
+// magic strings.
+const (
+ BSONFieldID = "_id"
+ BSONFieldTenantID = "tenant_id"
+ BSONFieldUID = "uid"
+
+ // permissions collection
+ BSONFieldName = "name"
+ BSONFieldParent = "parent"
+ BSONFieldHTTPMethods = "http_methods"
+ BSONFieldHTTPPath = "http_path"
+ BSONFieldStatus = "status"
+ BSONFieldType = "type"
+
+ // roles collection
+ BSONFieldKey = "key"
+ BSONFieldDisplayName = "display_name"
+ BSONFieldCreatorUID = "creator_uid"
+ BSONFieldIsSystem = "is_system"
+
+ // role_permissions
+ BSONFieldRoleID = "role_id"
+ BSONFieldPermissionID = "permission_id"
+
+ // user_roles
+ BSONFieldSource = "source"
+
+ // role_mappings
+ BSONFieldExternalSource = "external_source"
+ BSONFieldExternalKey = "external_key"
+ BSONFieldInternalRoleID = "internal_role_id"
+ BSONFieldInternalRoleKey = "internal_role_key"
+
+ BSONFieldCreateAt = "create_at"
+ BSONFieldUpdateAt = "update_at"
+)
+
+// Casbin policy section markers and reload pubsub channel.
+const (
+ CasbinPolicyType = "p"
+
+ // PolicyReloadChannel is the Redis Pub/Sub channel used to broadcast
+ // "tenant policy needs reload" events across pods. Payload is JSON:
+ // { "tenant_id": "xxx", "ts": 1716120000000 }
+ // tenant_id == "*" means full LoadAllPolicies.
+ PolicyReloadChannel = "casbin:reload"
+
+ // PolicyReloadAllToken is the wildcard for full reload.
+ PolicyReloadAllToken = "*"
+)
+
+// Role.Key constraints (identity-member-design.md §6.5).
+const (
+ RoleKeyMinLength = 2
+ RoleKeyMaxLength = 64
+ RoleDisplayNameMax = 128
+ PermissionNameMax = 128
+ HTTPPathMaxLength = 256
+ HTTPMethodsMaxLen = 64
+ ExternalKeyMaxLen = 256
+ RoleMappingPageSize = 50
+)
+
+// Reserved Role.Key prefixes that B2B tenants must not register.
+var ReservedRoleKeyPrefixes = []string{"system.", "platform_"}
diff --git a/internal/model/permission/domain/entity/permission.go b/internal/model/permission/domain/entity/permission.go
new file mode 100644
index 0000000..f2348f5
--- /dev/null
+++ b/internal/model/permission/domain/entity/permission.go
@@ -0,0 +1,37 @@
+package entity
+
+import (
+ "gateway/internal/model/permission/domain/enum"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// Permission is the platform-wide permission catalog node. Tenants may not
+// create permissions; they pick from the catalog when assigning to roles.
+//
+// Tree model: Parent holds the parent ObjectID hex (or empty for root).
+// Category nodes (no HTTPPath) are UI-only and never written to Casbin
+// policy.
+type Permission struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ Parent string `bson:"parent,omitempty"` // parent ObjectID hex; empty = root
+ Name string `bson:"name"` // dot-notation, unique platform-wide
+ HTTPMethods string `bson:"http_methods,omitempty"` // "GET" or "GET|POST|PATCH"
+ HTTPPath string `bson:"http_path,omitempty"` // keyMatch2 pattern, e.g. /api/v1/members/*
+ Status enum.Status `bson:"status"`
+ Type enum.PermissionType `bson:"type"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+// CollectionName returns the MongoDB collection for permissions.
+func (Permission) CollectionName() string {
+ return "permissions"
+}
+
+// IsLeaf reports whether the permission is a Casbin-enforceable leaf
+// (i.e. has both http_path and http_methods set). Category nodes return
+// false and are never written to policy rules.
+func (p *Permission) IsLeaf() bool {
+ return p != nil && p.HTTPPath != "" && p.HTTPMethods != ""
+}
diff --git a/internal/model/permission/domain/entity/role.go b/internal/model/permission/domain/entity/role.go
new file mode 100644
index 0000000..827309c
--- /dev/null
+++ b/internal/model/permission/domain/entity/role.go
@@ -0,0 +1,30 @@
+package entity
+
+import (
+ "gateway/internal/model/permission/domain/enum"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// Role is a tenant-scoped role definition. Key is immutable and uniquely
+// identifies the role within a tenant; DisplayName may be edited freely.
+//
+// is_system roles are seeded when a tenant is created (`tenant_owner`,
+// `tenant_admin`, `member_manager`, `member`, `viewer`); the `tenant_owner`
+// role cannot be deleted.
+type Role struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ TenantID string `bson:"tenant_id"`
+ Key string `bson:"key"`
+ DisplayName string `bson:"display_name"`
+ CreatorUID string `bson:"creator_uid,omitempty"`
+ Status enum.Status `bson:"status"`
+ IsSystem bool `bson:"is_system"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+// CollectionName returns the MongoDB collection for roles.
+func (Role) CollectionName() string {
+ return "roles"
+}
diff --git a/internal/model/permission/domain/entity/role_mapping.go b/internal/model/permission/domain/entity/role_mapping.go
new file mode 100644
index 0000000..c970db8
--- /dev/null
+++ b/internal/model/permission/domain/entity/role_mapping.go
@@ -0,0 +1,30 @@
+package entity
+
+import (
+ "gateway/internal/model/permission/domain/enum"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// RoleMapping links an external identity-provider group/role to an
+// internal tenant Role. SyncFromX flows look up via
+// (TenantID, ExternalSource, ExternalKey) to translate provider claims
+// into Role assignments.
+//
+// InternalRoleKey is denormalized for audit/query convenience.
+type RoleMapping struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ TenantID string `bson:"tenant_id"`
+ ExternalSource enum.RoleSource `bson:"external_source"`
+ ExternalKey string `bson:"external_key"`
+ InternalRoleID string `bson:"internal_role_id"`
+ InternalRoleKey string `bson:"internal_role_key"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+// CollectionName returns the MongoDB collection for external→internal
+// role mappings.
+func (RoleMapping) CollectionName() string {
+ return "role_mappings"
+}
diff --git a/internal/model/permission/domain/entity/role_permission.go b/internal/model/permission/domain/entity/role_permission.go
new file mode 100644
index 0000000..664ea0e
--- /dev/null
+++ b/internal/model/permission/domain/entity/role_permission.go
@@ -0,0 +1,20 @@
+package entity
+
+import "go.mongodb.org/mongo-driver/v2/bson"
+
+// RolePermission joins a Role to a Permission catalog entry. When the
+// usecase adds a leaf permission it MUST also insert all parent permission
+// IDs (see usecase.permission_tree.GetFullParentPermissionIDs).
+type RolePermission struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ TenantID string `bson:"tenant_id"`
+ RoleID string `bson:"role_id"` // Role._id hex
+ PermissionID string `bson:"permission_id"` // Permission._id hex
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+// CollectionName returns the MongoDB collection for role↔permission joins.
+func (RolePermission) CollectionName() string {
+ return "role_permissions"
+}
diff --git a/internal/model/permission/domain/entity/user_role.go b/internal/model/permission/domain/entity/user_role.go
new file mode 100644
index 0000000..54f0582
--- /dev/null
+++ b/internal/model/permission/domain/entity/user_role.go
@@ -0,0 +1,26 @@
+package entity
+
+import (
+ "gateway/internal/model/permission/domain/enum"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// UserRole assigns a Role to a member within a tenant. A member can hold
+// multiple roles (any-allow Casbin semantics); Source allows
+// SyncFromX flows to replace only their own assignments without touching
+// manual ones.
+type UserRole struct {
+ ID bson.ObjectID `bson:"_id,omitempty"`
+ TenantID string `bson:"tenant_id"`
+ UID string `bson:"uid"`
+ RoleID string `bson:"role_id"`
+ Source enum.RoleSource `bson:"source"`
+ CreateAt int64 `bson:"create_at"`
+ UpdateAt int64 `bson:"update_at"`
+}
+
+// CollectionName returns the MongoDB collection for user↔role joins.
+func (UserRole) CollectionName() string {
+ return "user_roles"
+}
diff --git a/internal/model/permission/domain/enum/permission_type.go b/internal/model/permission/domain/enum/permission_type.go
new file mode 100644
index 0000000..bb66fc3
--- /dev/null
+++ b/internal/model/permission/domain/enum/permission_type.go
@@ -0,0 +1,26 @@
+package enum
+
+// PermissionType separates backend-admin permissions from frontend-user
+// menu permissions; it is informational (UI only) and does not affect
+// Casbin enforcement.
+type PermissionType string
+
+const (
+ PermissionTypeBackendUser PermissionType = "backend_user"
+ PermissionTypeFrontendUser PermissionType = "frontend_user"
+)
+
+// IsValid reports whether t is a known permission type.
+func (t PermissionType) IsValid() bool {
+ switch t {
+ case PermissionTypeBackendUser, PermissionTypeFrontendUser:
+ return true
+ default:
+ return false
+ }
+}
+
+// String returns the raw type value.
+func (t PermissionType) String() string {
+ return string(t)
+}
diff --git a/internal/model/permission/domain/enum/role_source.go b/internal/model/permission/domain/enum/role_source.go
new file mode 100644
index 0000000..91acea1
--- /dev/null
+++ b/internal/model/permission/domain/enum/role_source.go
@@ -0,0 +1,28 @@
+package enum
+
+// RoleSource identifies the origin of a UserRole assignment. Sync-from-X
+// flows (zitadel/ldap/scim) only replace assignments of their own source;
+// manual stays sticky.
+type RoleSource string
+
+const (
+ RoleSourceManual RoleSource = "manual"
+ RoleSourceZitadel RoleSource = "zitadel"
+ RoleSourceLDAP RoleSource = "ldap"
+ RoleSourceSCIM RoleSource = "scim"
+)
+
+// IsValid reports whether s is a known role source.
+func (s RoleSource) IsValid() bool {
+ switch s {
+ case RoleSourceManual, RoleSourceZitadel, RoleSourceLDAP, RoleSourceSCIM:
+ return true
+ default:
+ return false
+ }
+}
+
+// String returns the raw source value.
+func (s RoleSource) String() string {
+ return string(s)
+}
diff --git a/internal/model/permission/domain/enum/status.go b/internal/model/permission/domain/enum/status.go
new file mode 100644
index 0000000..75667db
--- /dev/null
+++ b/internal/model/permission/domain/enum/status.go
@@ -0,0 +1,28 @@
+package enum
+
+// Status indicates whether a Permission or Role node is enabled.
+type Status string
+
+const (
+ StatusOpen Status = "open"
+ StatusClose Status = "close"
+)
+
+// IsValid reports whether s is a known status value.
+func (s Status) IsValid() bool {
+ switch s {
+ case StatusOpen, StatusClose:
+ return true
+ default:
+ return false
+ }
+}
+
+// String returns the raw status value.
+func (s Status) String() string {
+ return string(s)
+}
+
+// Permissions maps permission name → status, used by the
+// AuthorizationQuery layer when shaping the menu/permission map.
+type Permissions map[string]Status
diff --git a/internal/model/permission/domain/errors.go b/internal/model/permission/domain/errors.go
new file mode 100644
index 0000000..ac01ce5
--- /dev/null
+++ b/internal/model/permission/domain/errors.go
@@ -0,0 +1,29 @@
+package domain
+
+import "fmt"
+
+// Module-wide sentinel errors. They are intentionally untyped so callers
+// wrap them with library/errors.Builder when surfacing to HTTP/RPC layers.
+var (
+ ErrPermissionNotFound = fmt.Errorf("permission: permission not found")
+ ErrPermissionDup = fmt.Errorf("permission: duplicate permission")
+ ErrPermissionClosed = fmt.Errorf("permission: permission is closed")
+ ErrPermissionInTenant = fmt.Errorf("permission: permission not in catalog")
+
+ ErrRoleNotFound = fmt.Errorf("permission: role not found")
+ ErrRoleDuplicate = fmt.Errorf("permission: duplicate role key in tenant")
+ ErrRoleSystemImmutable = fmt.Errorf("permission: system role is immutable")
+ ErrRoleNotInTenant = fmt.Errorf("permission: role does not belong to tenant")
+ ErrRoleKeyReserved = fmt.Errorf("permission: role key uses reserved prefix")
+ ErrRoleKeyInvalid = fmt.Errorf("permission: role key format invalid")
+
+ ErrUserRoleNotFound = fmt.Errorf("permission: user role assignment not found")
+ ErrUserRoleDuplicate = fmt.Errorf("permission: duplicate user role assignment")
+
+ ErrRoleMappingNotFound = fmt.Errorf("permission: role mapping not found")
+ ErrRoleMappingDuplicate = fmt.Errorf("permission: duplicate role mapping")
+
+ ErrCasbinNotConfigured = fmt.Errorf("permission: casbin enforcer not configured")
+ ErrInvalidCheckRequest = fmt.Errorf("permission: invalid check request")
+ ErrInvalidStatus = fmt.Errorf("permission: invalid status value")
+)
diff --git a/internal/model/permission/domain/redis.go b/internal/model/permission/domain/redis.go
new file mode 100644
index 0000000..725f280
--- /dev/null
+++ b/internal/model/permission/domain/redis.go
@@ -0,0 +1,54 @@
+package domain
+
+import "strings"
+
+// RedisKey is the permission module Redis key prefix. Use the package-level
+// helpers (Get*RedisKey) instead of string concatenation so the layout stays
+// auditable.
+type RedisKey string
+
+// Key prefixes for the permission module. Layout matches
+// identity-member-design.md §14.
+const (
+ CasbinRulesRedisKey RedisKey = "permission:casbin:rules"
+ UserRolesRedisKey RedisKey = "perm:user_roles"
+ RolePermsRedisKey RedisKey = "perm:role_perms"
+ PermissionTreeKey RedisKey = "permission:tree:open"
+ PolicyReloadLockKey RedisKey = "permission:policy:reload:lock"
+ StepUpUsedRedisKey RedisKey = "permission:stepup:used"
+ PermissionAuthGenKey RedisKey = "auth:gen"
+)
+
+// With appends colon-separated parts to the key.
+func (key RedisKey) With(parts ...string) RedisKey {
+ if len(parts) == 0 {
+ return key
+ }
+ return RedisKey(string(key) + ":" + strings.Join(parts, ":"))
+}
+
+// String returns the raw key.
+func (key RedisKey) String() string {
+ return string(key)
+}
+
+// GetCasbinRulesRedisKey returns the tenant-scoped Casbin policy list key.
+func GetCasbinRulesRedisKey(tenantID string) string {
+ return CasbinRulesRedisKey.With(tenantID).String()
+}
+
+// GetUserRolesRedisKey returns the cache key for a user's role keys.
+func GetUserRolesRedisKey(tenantID, uid string) string {
+ return UserRolesRedisKey.With(tenantID, uid).String()
+}
+
+// GetRolePermsRedisKey returns the cache key for a role's permission names.
+func GetRolePermsRedisKey(tenantID, roleID string) string {
+ return RolePermsRedisKey.With(tenantID, roleID).String()
+}
+
+// GetAuthGenRedisKey returns the auth_gen revocation counter key. It mirrors
+// the auth module's namespace because permission changes also bump auth_gen.
+func GetAuthGenRedisKey(tenantID, uid string) string {
+ return PermissionAuthGenKey.With(tenantID, uid).String()
+}
diff --git a/internal/model/permission/domain/repository/casbin_adapter.go b/internal/model/permission/domain/repository/casbin_adapter.go
new file mode 100644
index 0000000..74ea531
--- /dev/null
+++ b/internal/model/permission/domain/repository/casbin_adapter.go
@@ -0,0 +1,33 @@
+package repository
+
+import "context"
+
+// CasbinPolicyAdapter is the persistence interface used by the RBAC
+// usecase to load/save Casbin policy for a single tenant. The Mongo /
+// Redis implementations live under repository/.
+//
+// A "rule" is the stringified Casbin tuple, e.g.
+//
+// ["p", "tenant_admin", "/api/v1/permissions/*", "GET|POST"]
+// ["g", "TENANT-100001", "tenant_admin"]
+//
+// Rule format mirrors casbin's [][]string convention exactly.
+type CasbinPolicyAdapter interface {
+ // LoadAll returns every rule for tenantID. An empty slice means
+ // "tenant has no policy" — callers should still call
+ // enforcer.LoadFilteredPolicy with the tenant filter.
+ LoadAll(ctx context.Context, tenantID string) ([][]string, error)
+
+ // SaveAll replaces all rules for tenantID with rules. Implementations
+ // MUST do this atomically (Redis MULTI / Mongo transaction).
+ SaveAll(ctx context.Context, tenantID string, rules [][]string) error
+
+ // AddPolicy adds a single rule.
+ AddPolicy(ctx context.Context, tenantID string, rule []string) error
+
+ // RemovePolicy removes a single rule.
+ RemovePolicy(ctx context.Context, tenantID string, rule []string) error
+
+ // Clear empties all rules for tenantID (used by tests + tenant disable).
+ Clear(ctx context.Context, tenantID string) error
+}
diff --git a/internal/model/permission/domain/repository/permission.go b/internal/model/permission/domain/repository/permission.go
new file mode 100644
index 0000000..e1750b8
--- /dev/null
+++ b/internal/model/permission/domain/repository/permission.go
@@ -0,0 +1,24 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// PermissionRepository persists the platform-wide Permission catalog.
+//
+// Catalog mutations are platform admin only; tenants read via
+// GetAll / GetByID. Insert is idempotent on Name (use UpsertByName when
+// seeding).
+type PermissionRepository interface {
+ Insert(ctx context.Context, perm *entity.Permission) error
+ UpsertByName(ctx context.Context, perm *entity.Permission) error
+ UpdateStatus(ctx context.Context, id string, status enum.Status) error
+ GetByID(ctx context.Context, id string) (*entity.Permission, error)
+ GetByName(ctx context.Context, name string) (*entity.Permission, error)
+ GetAll(ctx context.Context, status *enum.Status) ([]*entity.Permission, error)
+ GetByIDs(ctx context.Context, ids []string) ([]*entity.Permission, error)
+ GetByNames(ctx context.Context, names []string) ([]*entity.Permission, error)
+}
diff --git a/internal/model/permission/domain/repository/role.go b/internal/model/permission/domain/repository/role.go
new file mode 100644
index 0000000..7cecd1d
--- /dev/null
+++ b/internal/model/permission/domain/repository/role.go
@@ -0,0 +1,26 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+)
+
+// RoleUpdate carries optional patches for RoleRepository.Update. Pointer
+// fields preserve "absent" semantics so the usecase can run a partial
+// update without overwriting unchanged fields.
+type RoleUpdate struct {
+ DisplayName *string
+ Status *string
+}
+
+// RoleRepository persists tenant-scoped Role definitions.
+type RoleRepository interface {
+ Insert(ctx context.Context, role *entity.Role) error
+ GetByID(ctx context.Context, tenantID, id string) (*entity.Role, error)
+ GetByKey(ctx context.Context, tenantID, key string) (*entity.Role, error)
+ ListByTenant(ctx context.Context, tenantID string) ([]*entity.Role, error)
+ ListByTenantAndIDs(ctx context.Context, tenantID string, ids []string) ([]*entity.Role, error)
+ Update(ctx context.Context, tenantID, id string, update *RoleUpdate) (*entity.Role, error)
+ Delete(ctx context.Context, tenantID, id string) error
+}
diff --git a/internal/model/permission/domain/repository/role_mapping.go b/internal/model/permission/domain/repository/role_mapping.go
new file mode 100644
index 0000000..09d4068
--- /dev/null
+++ b/internal/model/permission/domain/repository/role_mapping.go
@@ -0,0 +1,30 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// RoleMappingRepository persists external IdP group → internal role maps.
+type RoleMappingRepository interface {
+ Insert(ctx context.Context, rm *entity.RoleMapping) error
+ Upsert(ctx context.Context, rm *entity.RoleMapping) error
+ Delete(ctx context.Context, tenantID string, source enum.RoleSource, externalKey string) error
+ DeleteByRole(ctx context.Context, tenantID, roleID string) (int64, error)
+
+ GetByExternal(
+ ctx context.Context,
+ tenantID string,
+ source enum.RoleSource,
+ externalKey string,
+ ) (*entity.RoleMapping, error)
+
+ ListByTenant(
+ ctx context.Context,
+ tenantID string,
+ source *enum.RoleSource,
+ offset, limit int64,
+ ) ([]*entity.RoleMapping, int64, error)
+}
diff --git a/internal/model/permission/domain/repository/role_permission.go b/internal/model/permission/domain/repository/role_permission.go
new file mode 100644
index 0000000..e3f04c3
--- /dev/null
+++ b/internal/model/permission/domain/repository/role_permission.go
@@ -0,0 +1,24 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+)
+
+// RolePermissionRepository manages the role↔permission catalog join.
+//
+// SetForRole replaces all permission IDs of a role atomically (delete +
+// bulk insert). Use it when the UI submits a "edit role permissions"
+// form so callers don't have to diff manually.
+type RolePermissionRepository interface {
+ Insert(ctx context.Context, rp *entity.RolePermission) error
+ BulkInsert(ctx context.Context, rps []*entity.RolePermission) error
+ DeleteByRole(ctx context.Context, tenantID, roleID string) error
+ DeleteByPermission(ctx context.Context, permissionID string) (int64, error)
+ SetForRole(ctx context.Context, tenantID, roleID string, permissionIDs []string) error
+
+ ListByRole(ctx context.Context, tenantID, roleID string) ([]*entity.RolePermission, error)
+ ListByRoles(ctx context.Context, tenantID string, roleIDs []string) ([]*entity.RolePermission, error)
+ ListByTenant(ctx context.Context, tenantID string) ([]*entity.RolePermission, error)
+}
diff --git a/internal/model/permission/domain/repository/user_role.go b/internal/model/permission/domain/repository/user_role.go
new file mode 100644
index 0000000..a4807b9
--- /dev/null
+++ b/internal/model/permission/domain/repository/user_role.go
@@ -0,0 +1,30 @@
+package repository
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// UserRoleRepository persists user↔role assignments.
+//
+// ReplaceForSource is the building block used by SyncFromX flows: it
+// removes existing assignments of the given source for (tenant_id, uid)
+// and inserts the new set in one transaction-equivalent step. Manual
+// assignments are untouched.
+type UserRoleRepository interface {
+ Insert(ctx context.Context, ur *entity.UserRole) error
+ BulkInsert(ctx context.Context, urs []*entity.UserRole) error
+ Delete(ctx context.Context, tenantID, uid, roleID string) error
+ DeleteByRole(ctx context.Context, tenantID, roleID string) (int64, error)
+ ReplaceForSource(
+ ctx context.Context,
+ tenantID, uid string,
+ source enum.RoleSource,
+ roleIDs []string,
+ ) error
+
+ ListByUser(ctx context.Context, tenantID, uid string) ([]*entity.UserRole, error)
+ ListByRole(ctx context.Context, tenantID, roleID string) ([]*entity.UserRole, error)
+}
diff --git a/internal/model/permission/domain/usecase/authorization_query.go b/internal/model/permission/domain/usecase/authorization_query.go
new file mode 100644
index 0000000..c62038b
--- /dev/null
+++ b/internal/model/permission/domain/usecase/authorization_query.go
@@ -0,0 +1,25 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// MePermissionsResponse is the shape consumed by frontend client code to
+// render menu/feature switches. Status enables the legacy permission-server
+// "open/close" pattern so keys can be hidden without removing rules.
+type MePermissionsResponse struct {
+ UID string `json:"uid"`
+ TenantID string `json:"tenant_id"`
+ Roles []string `json:"roles"`
+ Permissions enum.Permissions `json:"permissions"`
+ Tree []*PermissionTreeNode `json:"tree,omitempty"`
+}
+
+// AuthorizationQueryUseCase composes Role + RolePermission + Permission +
+// PermissionTree to materialise the "what can the current user see" map
+// returned by GET /permissions/me.
+type AuthorizationQueryUseCase interface {
+ Me(ctx context.Context, tenantID, uid string, includeTree bool) (*MePermissionsResponse, error)
+}
diff --git a/internal/model/permission/domain/usecase/permission.go b/internal/model/permission/domain/usecase/permission.go
new file mode 100644
index 0000000..7ba2c30
--- /dev/null
+++ b/internal/model/permission/domain/usecase/permission.go
@@ -0,0 +1,42 @@
+// Package usecase contains the permission module's domain interfaces and
+// DTOs. Implementations live under internal/model/permission/usecase/.
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// PermissionTreeNode is a hierarchical node returned by the catalog
+// endpoint. Children are nil when not requested or when the caller asked
+// for a flat list.
+type PermissionTreeNode struct {
+ ID string `json:"id"`
+ Parent string `json:"parent,omitempty"`
+ Name string `json:"name"`
+ HTTPMethods string `json:"http_methods,omitempty"`
+ HTTPPath string `json:"http_path,omitempty"`
+ Status enum.Status `json:"status"`
+ Type enum.PermissionType `json:"type"`
+ Children []*PermissionTreeNode `json:"children,omitempty"`
+}
+
+// CatalogQuery filters the catalog tree.
+type CatalogQuery struct {
+ OnlyOpen bool // exclude status=close (and their subtrees)
+ Type *enum.PermissionType // restrict to backend or frontend
+}
+
+// PermissionUseCase exposes the platform-wide permission catalog. Tenants
+// only consume read endpoints; mutations are platform-admin only and
+// usually run via cmd/permission-seed.
+type PermissionUseCase interface {
+ GetCatalogTree(ctx context.Context, query *CatalogQuery) ([]*PermissionTreeNode, error)
+ List(ctx context.Context, query *CatalogQuery) ([]*entity.Permission, error)
+ GetByID(ctx context.Context, id string) (*entity.Permission, error)
+ GetByName(ctx context.Context, name string) (*entity.Permission, error)
+ UpsertCatalog(ctx context.Context, perms []*entity.Permission) error
+ UpdateStatus(ctx context.Context, id string, status enum.Status) error
+}
diff --git a/internal/model/permission/domain/usecase/rbac.go b/internal/model/permission/domain/usecase/rbac.go
new file mode 100644
index 0000000..fe27f7d
--- /dev/null
+++ b/internal/model/permission/domain/usecase/rbac.go
@@ -0,0 +1,35 @@
+package usecase
+
+import "context"
+
+// CheckRequest is the standard input to the RBAC enforcer; mirrors the
+// Casbin policy header (sub, obj, act). TenantID is split out so the
+// loader can pick the right enforcer instance.
+type CheckRequest struct {
+ TenantID string
+ UID string // Casbin "sub" — typically `{tenant}:{uid}`
+ Path string // HTTP path; e.g. /api/v1/members/AMEX-100001
+ Method string // GET / POST / PATCH / DELETE / *
+}
+
+// CheckResult bundles the boolean answer with the matched permission so
+// audit logging can attribute the decision.
+type CheckResult struct {
+ Allow bool
+ MatchedRoleKey string
+ MatchedPolicyRow []string
+}
+
+// RBACUseCase wraps the per-tenant Casbin enforcer.
+//
+// LoadPolicy is the heavy operation (read all role_permission rows for a
+// tenant, materialise into [][]string and feed casbin); BroadcastReload
+// publishes via Redis Pub/Sub so other pods reload too.
+type RBACUseCase interface {
+ Check(ctx context.Context, req *CheckRequest) (*CheckResult, error)
+ LoadPolicy(ctx context.Context, tenantID string) error
+ LoadAllPolicies(ctx context.Context) error
+ BroadcastReload(ctx context.Context, tenantID string) error
+ StartReloadSubscriber(ctx context.Context) error
+ StopReloadSubscriber()
+}
diff --git a/internal/model/permission/domain/usecase/role.go b/internal/model/permission/domain/usecase/role.go
new file mode 100644
index 0000000..d351049
--- /dev/null
+++ b/internal/model/permission/domain/usecase/role.go
@@ -0,0 +1,35 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// CreateRoleParam carries the fields a tenant submits when creating a role.
+type CreateRoleParam struct {
+ TenantID string
+ Key string
+ DisplayName string
+ CreatorUID string
+ Status enum.Status // optional; defaults to open
+}
+
+// UpdateRoleParam patches an existing role. CRITICAL: Key is intentionally
+// omitted — keys are immutable so external mappings stay valid.
+type UpdateRoleParam struct {
+ DisplayName *string
+ Status *enum.Status
+}
+
+// RoleUseCase manages tenant-scoped role definitions. System roles
+// (is_system=true) are immutable except for DisplayName and refuse delete.
+type RoleUseCase interface {
+ Create(ctx context.Context, param *CreateRoleParam) (*entity.Role, error)
+ Get(ctx context.Context, tenantID, id string) (*entity.Role, error)
+ GetByKey(ctx context.Context, tenantID, key string) (*entity.Role, error)
+ List(ctx context.Context, tenantID string) ([]*entity.Role, error)
+ Update(ctx context.Context, tenantID, id string, param *UpdateRoleParam) (*entity.Role, error)
+ Delete(ctx context.Context, tenantID, id string) error
+}
diff --git a/internal/model/permission/domain/usecase/role_mapping.go b/internal/model/permission/domain/usecase/role_mapping.go
new file mode 100644
index 0000000..6630fe8
--- /dev/null
+++ b/internal/model/permission/domain/usecase/role_mapping.go
@@ -0,0 +1,40 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// UpsertMappingParam carries the fields a tenant admin submits when
+// editing role mappings. ExternalKey is opaque: for Zitadel it's the
+// project role key, for LDAP it's the group DN, for SCIM it's the group
+// displayName.
+type UpsertMappingParam struct {
+ TenantID string
+ ExternalSource enum.RoleSource
+ ExternalKey string
+ InternalRoleKey string
+}
+
+// ListMappingQuery filters role mapping queries.
+type ListMappingQuery struct {
+ Source *enum.RoleSource
+ Offset int64
+ Limit int64
+}
+
+// RoleMappingUseCase manages external→internal role mappings used by
+// SyncFromX flows.
+type RoleMappingUseCase interface {
+ Upsert(ctx context.Context, param *UpsertMappingParam) (*entity.RoleMapping, error)
+ Delete(ctx context.Context, tenantID string, source enum.RoleSource, externalKey string) error
+ GetByExternal(
+ ctx context.Context,
+ tenantID string,
+ source enum.RoleSource,
+ externalKey string,
+ ) (*entity.RoleMapping, error)
+ List(ctx context.Context, tenantID string, query *ListMappingQuery) ([]*entity.RoleMapping, int64, error)
+}
diff --git a/internal/model/permission/domain/usecase/role_permission.go b/internal/model/permission/domain/usecase/role_permission.go
new file mode 100644
index 0000000..0e5eee7
--- /dev/null
+++ b/internal/model/permission/domain/usecase/role_permission.go
@@ -0,0 +1,17 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+)
+
+// RolePermissionUseCase manages the role↔permission catalog assignments.
+//
+// Replace is the canonical "edit a role" call: the UI submits the full
+// permission ID set the user wants, and the usecase computes parent
+// closure + atomically rewrites role_permissions.
+type RolePermissionUseCase interface {
+ List(ctx context.Context, tenantID, roleID string) ([]*entity.Permission, error)
+ Replace(ctx context.Context, tenantID, roleID string, permissionIDs []string) error
+}
diff --git a/internal/model/permission/domain/usecase/user_role.go b/internal/model/permission/domain/usecase/user_role.go
new file mode 100644
index 0000000..c9671db
--- /dev/null
+++ b/internal/model/permission/domain/usecase/user_role.go
@@ -0,0 +1,38 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+)
+
+// AssignParam carries the fields needed to assign a role to a member.
+type AssignParam struct {
+ TenantID string
+ UID string
+ RoleID string
+ Source enum.RoleSource // defaults to manual when zero
+}
+
+// UserRoleSummary is what the API returns: a UserRole plus the resolved
+// Role.Key/Role.DisplayName, so clients don't need a second round trip.
+type UserRoleSummary struct {
+ *entity.UserRole
+ RoleKey string `json:"role_key"`
+ RoleDisplayName string `json:"role_display_name"`
+}
+
+// UserRoleUseCase manages user↔role assignments and exposes the building
+// block used by SyncFromX provisioning flows.
+type UserRoleUseCase interface {
+ Assign(ctx context.Context, param *AssignParam) (*entity.UserRole, error)
+ Revoke(ctx context.Context, tenantID, uid, roleID string) error
+ List(ctx context.Context, tenantID, uid string) ([]*UserRoleSummary, error)
+ ReplaceForSource(
+ ctx context.Context,
+ tenantID, uid string,
+ source enum.RoleSource,
+ roleKeys []string,
+ ) error
+}
diff --git a/internal/model/permission/repository/casbin_redis.go b/internal/model/permission/repository/casbin_redis.go
new file mode 100644
index 0000000..7e8db65
--- /dev/null
+++ b/internal/model/permission/repository/casbin_redis.go
@@ -0,0 +1,108 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ redislib "gateway/internal/library/redis"
+ permission "gateway/internal/model/permission/domain"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+// CasbinRedisAdapter is a tenant-scoped Redis-backed Casbin policy store.
+// Layout:
+//
+// permission:casbin:rules:{tenant_id} → Redis Set of JSON-encoded
+// []string rule rows.
+//
+// Atomicity: SaveAll uses a Pipelined DEL+SADD; AddPolicy/RemovePolicy
+// rely on Redis Set semantics (idempotent inserts, single-shot removes).
+type CasbinRedisAdapter struct {
+ client *redis.Redis
+}
+
+// NewCasbinRedisAdapter returns a CasbinPolicyAdapter backed by Redis.
+func NewCasbinRedisAdapter(client *redislib.Client) (domrepo.CasbinPolicyAdapter, error) {
+ if client == nil || client.Zero() == nil {
+ return nil, fmt.Errorf("permission: redis client is required for casbin adapter")
+ }
+ return &CasbinRedisAdapter{client: client.Zero()}, nil
+}
+
+func (a *CasbinRedisAdapter) key(tenantID string) string {
+ return permission.GetCasbinRulesRedisKey(tenantID)
+}
+
+// LoadAll returns every rule for tenantID.
+func (a *CasbinRedisAdapter) LoadAll(ctx context.Context, tenantID string) ([][]string, error) {
+ raw, err := a.client.SmembersCtx(ctx, a.key(tenantID))
+ if err != nil && !errors.Is(err, redis.Nil) {
+ return nil, err
+ }
+ rules := make([][]string, 0, len(raw))
+ for _, item := range raw {
+ var rule []string
+ if err := json.Unmarshal([]byte(item), &rule); err != nil {
+ continue
+ }
+ if len(rule) == 0 {
+ continue
+ }
+ rules = append(rules, rule)
+ }
+ return rules, nil
+}
+
+// SaveAll replaces all rules for tenantID with rules atomically.
+func (a *CasbinRedisAdapter) SaveAll(ctx context.Context, tenantID string, rules [][]string) error {
+ key := a.key(tenantID)
+ encoded := make([]any, 0, len(rules))
+ for _, rule := range rules {
+ raw, err := json.Marshal(rule)
+ if err != nil {
+ return fmt.Errorf("permission: marshal casbin rule: %w", err)
+ }
+ encoded = append(encoded, string(raw))
+ }
+ return a.client.PipelinedCtx(ctx, func(p redis.Pipeliner) error {
+ if err := p.Del(ctx, key).Err(); err != nil {
+ return err
+ }
+ if len(encoded) == 0 {
+ return nil
+ }
+ return p.SAdd(ctx, key, encoded...).Err()
+ })
+}
+
+// AddPolicy inserts a single rule (idempotent).
+func (a *CasbinRedisAdapter) AddPolicy(ctx context.Context, tenantID string, rule []string) error {
+ raw, err := json.Marshal(rule)
+ if err != nil {
+ return err
+ }
+ _, err = a.client.SaddCtx(ctx, a.key(tenantID), string(raw))
+ return err
+}
+
+// RemovePolicy removes a single rule.
+func (a *CasbinRedisAdapter) RemovePolicy(ctx context.Context, tenantID string, rule []string) error {
+ raw, err := json.Marshal(rule)
+ if err != nil {
+ return err
+ }
+ _, err = a.client.SremCtx(ctx, a.key(tenantID), string(raw))
+ return err
+}
+
+// Clear empties all rules for tenantID.
+func (a *CasbinRedisAdapter) Clear(ctx context.Context, tenantID string) error {
+ _, err := a.client.DelCtx(ctx, a.key(tenantID))
+ return err
+}
+
+var _ domrepo.CasbinPolicyAdapter = (*CasbinRedisAdapter)(nil)
diff --git a/internal/model/permission/repository/index.go b/internal/model/permission/repository/index.go
new file mode 100644
index 0000000..782ff52
--- /dev/null
+++ b/internal/model/permission/repository/index.go
@@ -0,0 +1,82 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+
+ libmongo "gateway/internal/library/mongo"
+)
+
+// MongoDB update-operator constants. Centralised so all permission Mongo
+// repos use the same literal and goconst stays quiet.
+const (
+ bsonOpSet = "$set"
+ bsonOpSetOnInsert = "$setOnInsert"
+ bsonOpIn = "$in"
+)
+
+// EnsureMongoIndexes creates indexes for permission module collections.
+// Safe to call repeatedly; index creation is idempotent in MongoDB.
+func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ if conf == nil || conf.Host == "" {
+ return nil
+ }
+ if err := ensurePermissionIndexes(ctx, conf); err != nil {
+ return err
+ }
+ if err := ensureRoleIndexes(ctx, conf); err != nil {
+ return err
+ }
+ if err := ensureRolePermissionIndexes(ctx, conf); err != nil {
+ return err
+ }
+ if err := ensureUserRoleIndexes(ctx, conf); err != nil {
+ return err
+ }
+ return ensureRoleMappingIndexes(ctx, conf)
+}
+
+func ensurePermissionIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewPermissionRepository(PermissionRepositoryParam{Conf: conf}).(*permissionRepository)
+ if !ok {
+ return fmt.Errorf("permission: unexpected permission repository type")
+ }
+ return repo.Index20260521001UP(ctx)
+}
+
+func ensureRoleIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewRoleRepository(RoleRepositoryParam{Conf: conf}).(*roleRepository)
+ if !ok {
+ return fmt.Errorf("permission: unexpected role repository type")
+ }
+ return repo.Index20260521001UP(ctx)
+}
+
+func ensureRolePermissionIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewRolePermissionRepository(RolePermissionRepositoryParam{Conf: conf}).(*rolePermissionRepository)
+ if !ok {
+ return fmt.Errorf("permission: unexpected role_permission repository type")
+ }
+ return repo.Index20260521001UP(ctx)
+}
+
+func ensureUserRoleIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewUserRoleRepository(UserRoleRepositoryParam{Conf: conf}).(*userRoleRepository)
+ if !ok {
+ return fmt.Errorf("permission: unexpected user_role repository type")
+ }
+ return repo.Index20260521001UP(ctx)
+}
+
+func ensureRoleMappingIndexes(ctx context.Context, conf *libmongo.Conf) error {
+ //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx
+ repo, ok := NewRoleMappingRepository(RoleMappingRepositoryParam{Conf: conf}).(*roleMappingRepository)
+ if !ok {
+ return fmt.Errorf("permission: unexpected role_mapping repository type")
+ }
+ return repo.Index20260521001UP(ctx)
+}
diff --git a/internal/model/permission/repository/permission_mongo.go b/internal/model/permission/repository/permission_mongo.go
new file mode 100644
index 0000000..122e483
--- /dev/null
+++ b/internal/model/permission/repository/permission_mongo.go
@@ -0,0 +1,195 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ permission "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+)
+
+// PermissionRepositoryParam configures the Mongo permission repository.
+type PermissionRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type permissionRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewPermissionRepository creates a Mongo-backed PermissionRepository.
+func NewPermissionRepository(param PermissionRepositoryParam) domrepo.PermissionRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Permission{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return &permissionRepository{db: documentDB}
+}
+
+func (r *permissionRepository) Insert(ctx context.Context, perm *entity.Permission) error {
+ now := time.Now().UTC().UnixMilli()
+ if perm.ID.IsZero() {
+ perm.ID = bson.NewObjectID()
+ }
+ if perm.CreateAt == 0 {
+ perm.CreateAt = now
+ }
+ if perm.UpdateAt == 0 {
+ perm.UpdateAt = now
+ }
+ _, err := r.db.GetClient().InsertOne(ctx, perm)
+ if err != nil {
+ if mongodriver.IsDuplicateKeyError(err) {
+ return permission.ErrPermissionDup
+ }
+ return err
+ }
+ return nil
+}
+
+func (r *permissionRepository) UpsertByName(ctx context.Context, perm *entity.Permission) error {
+ now := time.Now().UTC().UnixMilli()
+ if perm.UpdateAt == 0 {
+ perm.UpdateAt = now
+ }
+
+ filter := bson.M{permission.BSONFieldName: perm.Name}
+ set := bson.M{
+ permission.BSONFieldParent: perm.Parent,
+ permission.BSONFieldName: perm.Name,
+ permission.BSONFieldHTTPMethods: perm.HTTPMethods,
+ permission.BSONFieldHTTPPath: perm.HTTPPath,
+ permission.BSONFieldStatus: perm.Status,
+ permission.BSONFieldType: perm.Type,
+ permission.BSONFieldUpdateAt: perm.UpdateAt,
+ }
+ insert := bson.M{
+ permission.BSONFieldCreateAt: now,
+ }
+
+ update := bson.M{
+ bsonOpSet: set,
+ bsonOpSetOnInsert: insert,
+ }
+ _, err := r.db.GetClient().UpdateOne(ctx, filter, update, options.UpdateOne().SetUpsert(true))
+ return err
+}
+
+func (r *permissionRepository) UpdateStatus(ctx context.Context, id string, status enum.Status) error {
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ return permission.ErrPermissionNotFound
+ }
+ now := time.Now().UTC().UnixMilli()
+ filter := bson.M{permission.BSONFieldID: objID}
+ set := bson.M{
+ permission.BSONFieldStatus: status,
+ permission.BSONFieldUpdateAt: now,
+ }
+ res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set})
+ if err != nil {
+ return err
+ }
+ if res.MatchedCount == 0 {
+ return permission.ErrPermissionNotFound
+ }
+ return nil
+}
+
+func (r *permissionRepository) GetByID(ctx context.Context, id string) (*entity.Permission, error) {
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ return nil, permission.ErrPermissionNotFound
+ }
+ var doc entity.Permission
+ if err := r.db.GetClient().FindOne(ctx, &doc, bson.M{permission.BSONFieldID: objID}); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, permission.ErrPermissionNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *permissionRepository) GetByName(ctx context.Context, name string) (*entity.Permission, error) {
+ var doc entity.Permission
+ if err := r.db.GetClient().FindOne(ctx, &doc, bson.M{permission.BSONFieldName: name}); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, permission.ErrPermissionNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *permissionRepository) GetAll(ctx context.Context, status *enum.Status) ([]*entity.Permission, error) {
+ q := bson.M{}
+ if status != nil {
+ q[permission.BSONFieldStatus] = *status
+ }
+ opts := options.Find().SetSort(bson.D{{Key: permission.BSONFieldName, Value: 1}})
+ var docs []*entity.Permission
+ if err := r.db.GetClient().Find(ctx, &docs, q, opts); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *permissionRepository) GetByIDs(ctx context.Context, ids []string) ([]*entity.Permission, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ objIDs := make([]bson.ObjectID, 0, len(ids))
+ for _, id := range ids {
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ continue
+ }
+ objIDs = append(objIDs, objID)
+ }
+ if len(objIDs) == 0 {
+ return nil, nil
+ }
+ q := bson.M{permission.BSONFieldID: bson.M{bsonOpIn: objIDs}}
+ var docs []*entity.Permission
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *permissionRepository) GetByNames(ctx context.Context, names []string) ([]*entity.Permission, error) {
+ if len(names) == 0 {
+ return nil, nil
+ }
+ q := bson.M{permission.BSONFieldName: bson.M{bsonOpIn: names}}
+ var docs []*entity.Permission
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+// Index20260521001UP ensures permissions collection indexes exist.
+func (r *permissionRepository) Index20260521001UP(ctx context.Context) error {
+ if err := r.db.PopulateIndex(ctx, permission.BSONFieldName, 1, true); err != nil {
+ return err
+ }
+ if err := r.db.PopulateIndex(ctx, permission.BSONFieldParent, 1, false); err != nil {
+ return err
+ }
+ if err := r.db.PopulateIndex(ctx, permission.BSONFieldStatus, 1, false); err != nil {
+ return err
+ }
+ return r.db.PopulateIndex(ctx, permission.BSONFieldType, 1, false)
+}
+
+var _ domrepo.PermissionRepository = (*permissionRepository)(nil)
diff --git a/internal/model/permission/repository/role_mapping_mongo.go b/internal/model/permission/repository/role_mapping_mongo.go
new file mode 100644
index 0000000..7838b88
--- /dev/null
+++ b/internal/model/permission/repository/role_mapping_mongo.go
@@ -0,0 +1,182 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ permission "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+)
+
+// RoleMappingRepositoryParam configures the Mongo role mapping repository.
+type RoleMappingRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type roleMappingRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewRoleMappingRepository creates a Mongo-backed RoleMappingRepository.
+func NewRoleMappingRepository(param RoleMappingRepositoryParam) domrepo.RoleMappingRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.RoleMapping{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return &roleMappingRepository{db: documentDB}
+}
+
+func (r *roleMappingRepository) Insert(ctx context.Context, rm *entity.RoleMapping) error {
+ now := time.Now().UTC().UnixMilli()
+ if rm.ID.IsZero() {
+ rm.ID = bson.NewObjectID()
+ }
+ if rm.CreateAt == 0 {
+ rm.CreateAt = now
+ }
+ if rm.UpdateAt == 0 {
+ rm.UpdateAt = now
+ }
+ _, err := r.db.GetClient().InsertOne(ctx, rm)
+ if err != nil {
+ if mongodriver.IsDuplicateKeyError(err) {
+ return permission.ErrRoleMappingDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+func (r *roleMappingRepository) Upsert(ctx context.Context, rm *entity.RoleMapping) error {
+ now := time.Now().UTC().UnixMilli()
+ if rm.UpdateAt == 0 {
+ rm.UpdateAt = now
+ }
+ filter := bson.M{
+ permission.BSONFieldTenantID: rm.TenantID,
+ permission.BSONFieldExternalSource: rm.ExternalSource,
+ permission.BSONFieldExternalKey: rm.ExternalKey,
+ }
+ set := bson.M{
+ permission.BSONFieldInternalRoleID: rm.InternalRoleID,
+ permission.BSONFieldInternalRoleKey: rm.InternalRoleKey,
+ permission.BSONFieldUpdateAt: rm.UpdateAt,
+ }
+ insert := bson.M{
+ permission.BSONFieldTenantID: rm.TenantID,
+ permission.BSONFieldExternalSource: rm.ExternalSource,
+ permission.BSONFieldExternalKey: rm.ExternalKey,
+ permission.BSONFieldCreateAt: now,
+ }
+ _, err := r.db.GetClient().UpdateOne(ctx, filter,
+ bson.M{bsonOpSet: set, bsonOpSetOnInsert: insert},
+ options.UpdateOne().SetUpsert(true))
+ return err
+}
+
+func (r *roleMappingRepository) Delete(
+ ctx context.Context,
+ tenantID string,
+ source enum.RoleSource,
+ externalKey string,
+) error {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldExternalSource: source,
+ permission.BSONFieldExternalKey: externalKey,
+ }
+ res, err := r.db.GetClient().DeleteOne(ctx, filter)
+ if err != nil {
+ return err
+ }
+ if res == 0 {
+ return permission.ErrRoleMappingNotFound
+ }
+ return nil
+}
+
+func (r *roleMappingRepository) DeleteByRole(ctx context.Context, tenantID, roleID string) (int64, error) {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldInternalRoleID: roleID,
+ }
+ return r.db.GetClient().DeleteMany(ctx, filter)
+}
+
+func (r *roleMappingRepository) GetByExternal(
+ ctx context.Context,
+ tenantID string,
+ source enum.RoleSource,
+ externalKey string,
+) (*entity.RoleMapping, error) {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldExternalSource: source,
+ permission.BSONFieldExternalKey: externalKey,
+ }
+ var doc entity.RoleMapping
+ if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, permission.ErrRoleMappingNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *roleMappingRepository) ListByTenant(
+ ctx context.Context,
+ tenantID string,
+ source *enum.RoleSource,
+ offset, limit int64,
+) ([]*entity.RoleMapping, int64, error) {
+ q := bson.M{permission.BSONFieldTenantID: tenantID}
+ if source != nil {
+ q[permission.BSONFieldExternalSource] = *source
+ }
+ total, err := r.db.GetClient().CountDocuments(ctx, q)
+ if err != nil {
+ return nil, 0, err
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 200 {
+ limit = 200
+ }
+ opts := options.Find().
+ SetSkip(offset).
+ SetLimit(limit).
+ SetSort(bson.D{{Key: permission.BSONFieldCreateAt, Value: -1}})
+ var docs []*entity.RoleMapping
+ if err := r.db.GetClient().Find(ctx, &docs, q, opts); err != nil {
+ return nil, 0, err
+ }
+ return docs, total, nil
+}
+
+// Index20260521001UP ensures role_mappings collection indexes exist.
+func (r *roleMappingRepository) Index20260521001UP(ctx context.Context) error {
+ if err := r.db.PopulateMultiIndex(ctx,
+ []string{
+ permission.BSONFieldTenantID,
+ permission.BSONFieldExternalSource,
+ permission.BSONFieldExternalKey,
+ },
+ []int32{1, 1, 1}, true); err != nil {
+ return err
+ }
+ return r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldInternalRoleID},
+ []int32{1, 1}, false)
+}
+
+var _ domrepo.RoleMappingRepository = (*roleMappingRepository)(nil)
diff --git a/internal/model/permission/repository/role_mongo.go b/internal/model/permission/repository/role_mongo.go
new file mode 100644
index 0000000..8e2485d
--- /dev/null
+++ b/internal/model/permission/repository/role_mongo.go
@@ -0,0 +1,192 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ permission "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+ "go.mongodb.org/mongo-driver/v2/mongo/options"
+)
+
+// RoleRepositoryParam configures the Mongo role repository.
+type RoleRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type roleRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewRoleRepository creates a Mongo-backed RoleRepository.
+func NewRoleRepository(param RoleRepositoryParam) domrepo.RoleRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Role{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return &roleRepository{db: documentDB}
+}
+
+func (r *roleRepository) Insert(ctx context.Context, role *entity.Role) error {
+ now := time.Now().UTC().UnixMilli()
+ if role.ID.IsZero() {
+ role.ID = bson.NewObjectID()
+ }
+ if role.CreateAt == 0 {
+ role.CreateAt = now
+ }
+ if role.UpdateAt == 0 {
+ role.UpdateAt = now
+ }
+ _, err := r.db.GetClient().InsertOne(ctx, role)
+ if err != nil {
+ if mongodriver.IsDuplicateKeyError(err) {
+ return permission.ErrRoleDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+func (r *roleRepository) GetByID(ctx context.Context, tenantID, id string) (*entity.Role, error) {
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ return nil, permission.ErrRoleNotFound
+ }
+ var doc entity.Role
+ filter := bson.M{
+ permission.BSONFieldID: objID,
+ permission.BSONFieldTenantID: tenantID,
+ }
+ if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, permission.ErrRoleNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *roleRepository) GetByKey(ctx context.Context, tenantID, key string) (*entity.Role, error) {
+ var doc entity.Role
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldKey: key,
+ }
+ if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, permission.ErrRoleNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *roleRepository) ListByTenant(ctx context.Context, tenantID string) ([]*entity.Role, error) {
+ q := bson.M{permission.BSONFieldTenantID: tenantID}
+ opts := options.Find().SetSort(bson.D{
+ {Key: permission.BSONFieldIsSystem, Value: -1},
+ {Key: permission.BSONFieldKey, Value: 1},
+ })
+ var docs []*entity.Role
+ if err := r.db.GetClient().Find(ctx, &docs, q, opts); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *roleRepository) ListByTenantAndIDs(ctx context.Context, tenantID string, ids []string) ([]*entity.Role, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ objIDs := make([]bson.ObjectID, 0, len(ids))
+ for _, id := range ids {
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ continue
+ }
+ objIDs = append(objIDs, objID)
+ }
+ if len(objIDs) == 0 {
+ return nil, nil
+ }
+ q := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldID: bson.M{bsonOpIn: objIDs},
+ }
+ var docs []*entity.Role
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *roleRepository) Update(ctx context.Context, tenantID, id string, update *domrepo.RoleUpdate) (*entity.Role, error) {
+ if update == nil {
+ return r.GetByID(ctx, tenantID, id)
+ }
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ return nil, permission.ErrRoleNotFound
+ }
+ now := time.Now().UTC().UnixMilli()
+ set := bson.M{permission.BSONFieldUpdateAt: now}
+ if update.DisplayName != nil {
+ set[permission.BSONFieldDisplayName] = *update.DisplayName
+ }
+ if update.Status != nil {
+ set[permission.BSONFieldStatus] = *update.Status
+ }
+ filter := bson.M{
+ permission.BSONFieldID: objID,
+ permission.BSONFieldTenantID: tenantID,
+ }
+ var doc entity.Role
+ opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
+ if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{bsonOpSet: set}, opts); err != nil {
+ if errors.Is(err, mongodriver.ErrNoDocuments) {
+ return nil, permission.ErrRoleNotFound
+ }
+ return nil, err
+ }
+ return &doc, nil
+}
+
+func (r *roleRepository) Delete(ctx context.Context, tenantID, id string) error {
+ objID, err := bson.ObjectIDFromHex(id)
+ if err != nil {
+ return permission.ErrRoleNotFound
+ }
+ filter := bson.M{
+ permission.BSONFieldID: objID,
+ permission.BSONFieldTenantID: tenantID,
+ }
+ res, err := r.db.GetClient().DeleteOne(ctx, filter)
+ if err != nil {
+ return err
+ }
+ if res == 0 {
+ return permission.ErrRoleNotFound
+ }
+ return nil
+}
+
+// Index20260521001UP ensures roles collection indexes exist.
+func (r *roleRepository) Index20260521001UP(ctx context.Context) error {
+ if err := r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldKey},
+ []int32{1, 1}, true); err != nil {
+ return err
+ }
+ return r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldIsSystem},
+ []int32{1, 1}, false)
+}
+
+var _ domrepo.RoleRepository = (*roleRepository)(nil)
diff --git a/internal/model/permission/repository/role_permission_mongo.go b/internal/model/permission/repository/role_permission_mongo.go
new file mode 100644
index 0000000..fa066ad
--- /dev/null
+++ b/internal/model/permission/repository/role_permission_mongo.go
@@ -0,0 +1,176 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ permission "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+)
+
+// RolePermissionRepositoryParam configures the Mongo role-permission repository.
+type RolePermissionRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type rolePermissionRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewRolePermissionRepository creates a Mongo-backed RolePermissionRepository.
+func NewRolePermissionRepository(param RolePermissionRepositoryParam) domrepo.RolePermissionRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.RolePermission{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return &rolePermissionRepository{db: documentDB}
+}
+
+func (r *rolePermissionRepository) Insert(ctx context.Context, rp *entity.RolePermission) error {
+ now := time.Now().UTC().UnixMilli()
+ if rp.ID.IsZero() {
+ rp.ID = bson.NewObjectID()
+ }
+ if rp.CreateAt == 0 {
+ rp.CreateAt = now
+ }
+ if rp.UpdateAt == 0 {
+ rp.UpdateAt = now
+ }
+ _, err := r.db.GetClient().InsertOne(ctx, rp)
+ if err != nil && mongodriver.IsDuplicateKeyError(err) {
+ return nil
+ }
+ return err
+}
+
+func (r *rolePermissionRepository) BulkInsert(ctx context.Context, rps []*entity.RolePermission) error {
+ if len(rps) == 0 {
+ return nil
+ }
+ now := time.Now().UTC().UnixMilli()
+ docs := make([]any, 0, len(rps))
+ for _, rp := range rps {
+ if rp.ID.IsZero() {
+ rp.ID = bson.NewObjectID()
+ }
+ if rp.CreateAt == 0 {
+ rp.CreateAt = now
+ }
+ if rp.UpdateAt == 0 {
+ rp.UpdateAt = now
+ }
+ docs = append(docs, rp)
+ }
+ _, err := r.db.GetClient().InsertMany(ctx, docs)
+ if err != nil && !mongodriver.IsDuplicateKeyError(err) {
+ return err
+ }
+ return nil
+}
+
+func (r *rolePermissionRepository) DeleteByRole(ctx context.Context, tenantID, roleID string) error {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldRoleID: roleID,
+ }
+ _, err := r.db.GetClient().DeleteMany(ctx, filter)
+ return err
+}
+
+func (r *rolePermissionRepository) DeleteByPermission(ctx context.Context, permissionID string) (int64, error) {
+ filter := bson.M{permission.BSONFieldPermissionID: permissionID}
+ res, err := r.db.GetClient().DeleteMany(ctx, filter)
+ return res, err
+}
+
+func (r *rolePermissionRepository) SetForRole(
+ ctx context.Context,
+ tenantID, roleID string,
+ permissionIDs []string,
+) error {
+ if err := r.DeleteByRole(ctx, tenantID, roleID); err != nil {
+ return err
+ }
+ if len(permissionIDs) == 0 {
+ return nil
+ }
+ now := time.Now().UTC().UnixMilli()
+ rows := make([]*entity.RolePermission, 0, len(permissionIDs))
+ for _, pid := range permissionIDs {
+ rows = append(rows, &entity.RolePermission{
+ ID: bson.NewObjectID(),
+ TenantID: tenantID,
+ RoleID: roleID,
+ PermissionID: pid,
+ CreateAt: now,
+ UpdateAt: now,
+ })
+ }
+ return r.BulkInsert(ctx, rows)
+}
+
+func (r *rolePermissionRepository) ListByRole(
+ ctx context.Context,
+ tenantID, roleID string,
+) ([]*entity.RolePermission, error) {
+ q := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldRoleID: roleID,
+ }
+ var docs []*entity.RolePermission
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *rolePermissionRepository) ListByRoles(
+ ctx context.Context,
+ tenantID string,
+ roleIDs []string,
+) ([]*entity.RolePermission, error) {
+ if len(roleIDs) == 0 {
+ return nil, nil
+ }
+ q := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldRoleID: bson.M{bsonOpIn: roleIDs},
+ }
+ var docs []*entity.RolePermission
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *rolePermissionRepository) ListByTenant(
+ ctx context.Context,
+ tenantID string,
+) ([]*entity.RolePermission, error) {
+ q := bson.M{permission.BSONFieldTenantID: tenantID}
+ var docs []*entity.RolePermission
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+// Index20260521001UP ensures role_permissions collection indexes exist.
+func (r *rolePermissionRepository) Index20260521001UP(ctx context.Context) error {
+ if err := r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldRoleID, permission.BSONFieldPermissionID},
+ []int32{1, 1, 1}, true); err != nil {
+ return err
+ }
+ return r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldPermissionID},
+ []int32{1, 1}, false)
+}
+
+var _ domrepo.RolePermissionRepository = (*rolePermissionRepository)(nil)
diff --git a/internal/model/permission/repository/user_role_mongo.go b/internal/model/permission/repository/user_role_mongo.go
new file mode 100644
index 0000000..b9c6ad9
--- /dev/null
+++ b/internal/model/permission/repository/user_role_mongo.go
@@ -0,0 +1,185 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ libmongo "gateway/internal/library/mongo"
+ permission "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+ mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
+)
+
+// UserRoleRepositoryParam configures the Mongo user-role repository.
+type UserRoleRepositoryParam struct {
+ Conf *libmongo.Conf
+}
+
+type userRoleRepository struct {
+ db libmongo.DocumentDBUseCase
+}
+
+// NewUserRoleRepository creates a Mongo-backed UserRoleRepository.
+func NewUserRoleRepository(param UserRoleRepositoryParam) domrepo.UserRoleRepository {
+ documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.UserRole{}.CollectionName())
+ if err != nil {
+ panic(err)
+ }
+ return &userRoleRepository{db: documentDB}
+}
+
+func (r *userRoleRepository) Insert(ctx context.Context, ur *entity.UserRole) error {
+ now := time.Now().UTC().UnixMilli()
+ if ur.ID.IsZero() {
+ ur.ID = bson.NewObjectID()
+ }
+ if ur.Source == "" {
+ ur.Source = enum.RoleSourceManual
+ }
+ if ur.CreateAt == 0 {
+ ur.CreateAt = now
+ }
+ if ur.UpdateAt == 0 {
+ ur.UpdateAt = now
+ }
+ _, err := r.db.GetClient().InsertOne(ctx, ur)
+ if err != nil {
+ if mongodriver.IsDuplicateKeyError(err) {
+ return permission.ErrUserRoleDuplicate
+ }
+ return err
+ }
+ return nil
+}
+
+func (r *userRoleRepository) BulkInsert(ctx context.Context, urs []*entity.UserRole) error {
+ if len(urs) == 0 {
+ return nil
+ }
+ now := time.Now().UTC().UnixMilli()
+ docs := make([]any, 0, len(urs))
+ for _, ur := range urs {
+ if ur.ID.IsZero() {
+ ur.ID = bson.NewObjectID()
+ }
+ if ur.Source == "" {
+ ur.Source = enum.RoleSourceManual
+ }
+ if ur.CreateAt == 0 {
+ ur.CreateAt = now
+ }
+ if ur.UpdateAt == 0 {
+ ur.UpdateAt = now
+ }
+ docs = append(docs, ur)
+ }
+ _, err := r.db.GetClient().InsertMany(ctx, docs)
+ if err != nil && !mongodriver.IsDuplicateKeyError(err) {
+ return err
+ }
+ return nil
+}
+
+func (r *userRoleRepository) Delete(ctx context.Context, tenantID, uid, roleID string) error {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldUID: uid,
+ permission.BSONFieldRoleID: roleID,
+ }
+ res, err := r.db.GetClient().DeleteOne(ctx, filter)
+ if err != nil {
+ return err
+ }
+ if res == 0 {
+ return permission.ErrUserRoleNotFound
+ }
+ return nil
+}
+
+func (r *userRoleRepository) DeleteByRole(ctx context.Context, tenantID, roleID string) (int64, error) {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldRoleID: roleID,
+ }
+ return r.db.GetClient().DeleteMany(ctx, filter)
+}
+
+func (r *userRoleRepository) ReplaceForSource(
+ ctx context.Context,
+ tenantID, uid string,
+ source enum.RoleSource,
+ roleIDs []string,
+) error {
+ filter := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldUID: uid,
+ permission.BSONFieldSource: source,
+ }
+ if _, err := r.db.GetClient().DeleteMany(ctx, filter); err != nil {
+ return err
+ }
+ if len(roleIDs) == 0 {
+ return nil
+ }
+ now := time.Now().UTC().UnixMilli()
+ rows := make([]*entity.UserRole, 0, len(roleIDs))
+ for _, rid := range roleIDs {
+ rows = append(rows, &entity.UserRole{
+ ID: bson.NewObjectID(),
+ TenantID: tenantID,
+ UID: uid,
+ RoleID: rid,
+ Source: source,
+ CreateAt: now,
+ UpdateAt: now,
+ })
+ }
+ return r.BulkInsert(ctx, rows)
+}
+
+func (r *userRoleRepository) ListByUser(ctx context.Context, tenantID, uid string) ([]*entity.UserRole, error) {
+ q := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldUID: uid,
+ }
+ var docs []*entity.UserRole
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+func (r *userRoleRepository) ListByRole(ctx context.Context, tenantID, roleID string) ([]*entity.UserRole, error) {
+ q := bson.M{
+ permission.BSONFieldTenantID: tenantID,
+ permission.BSONFieldRoleID: roleID,
+ }
+ var docs []*entity.UserRole
+ if err := r.db.GetClient().Find(ctx, &docs, q); err != nil {
+ return nil, err
+ }
+ return docs, nil
+}
+
+// Index20260521001UP ensures user_roles collection indexes exist.
+func (r *userRoleRepository) Index20260521001UP(ctx context.Context) error {
+ if err := r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldUID, permission.BSONFieldRoleID},
+ []int32{1, 1, 1}, true); err != nil {
+ return err
+ }
+ if err := r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldRoleID},
+ []int32{1, 1}, false); err != nil {
+ return err
+ }
+ return r.db.PopulateMultiIndex(ctx,
+ []string{permission.BSONFieldTenantID, permission.BSONFieldUID, permission.BSONFieldSource},
+ []int32{1, 1, 1}, false)
+}
+
+var _ domrepo.UserRoleRepository = (*userRoleRepository)(nil)
diff --git a/internal/model/permission/seed/catalog.go b/internal/model/permission/seed/catalog.go
new file mode 100644
index 0000000..6a187d6
--- /dev/null
+++ b/internal/model/permission/seed/catalog.go
@@ -0,0 +1,277 @@
+// Package seed provides the embedded permission catalog and default
+// system role set used by cmd/permission-seed and the test fixture.
+package seed
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+//go:embed catalog.json
+var catalogJSON []byte
+
+// CatalogEntry mirrors the JSON shape on disk. Parent / Description are
+// optional; HTTPMethods + HTTPPath empty marks a category node.
+type CatalogEntry struct {
+ Name string `json:"name"`
+ Parent string `json:"parent,omitempty"`
+ HTTPMethods string `json:"http_methods,omitempty"`
+ HTTPPath string `json:"http_path,omitempty"`
+ Type string `json:"type"`
+ Status string `json:"status,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+// LoadCatalog returns the embedded catalog as parsed entries.
+func LoadCatalog() ([]*CatalogEntry, error) {
+ var entries []*CatalogEntry
+ if err := json.Unmarshal(catalogJSON, &entries); err != nil {
+ return nil, fmt.Errorf("permission seed: parse catalog: %w", err)
+ }
+ return entries, nil
+}
+
+// SystemRoleDefinition is a default role seeded for every B2B tenant on
+// creation. PermissionNames are catalog entries by Name; the seeder
+// resolves them to IDs at apply-time.
+type SystemRoleDefinition struct {
+ Key string
+ DisplayName string
+ PermissionNames []string
+}
+
+// DefaultSystemRoles is the canonical set assigned to every new tenant
+// per design §6.5. tenant_owner is undeletable; the rest can be edited.
+var DefaultSystemRoles = []SystemRoleDefinition{
+ {
+ Key: "tenant_owner",
+ DisplayName: "Tenant Owner",
+ PermissionNames: []string{
+ "member.info.management",
+ "permission.role.management",
+ "system.management",
+ },
+ },
+ {
+ Key: "tenant_admin",
+ DisplayName: "Tenant Admin",
+ PermissionNames: []string{
+ "member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
+ "permission.role.read", "permission.role.write", "permission.assign.write",
+ "permission.mapping.write", "permission.policy.reload",
+ },
+ },
+ {
+ Key: "member_manager",
+ DisplayName: "Member Manager",
+ PermissionNames: []string{
+ "member.admin.list", "member.admin.read", "member.admin.update", "member.admin.status",
+ },
+ },
+ {
+ Key: "member",
+ DisplayName: "Member",
+ PermissionNames: []string{
+ "member.info.select",
+ "member.info.update",
+ },
+ },
+ {
+ Key: "viewer",
+ DisplayName: "Viewer",
+ PermissionNames: []string{
+ "member.info.select",
+ "permission.role.read",
+ },
+ },
+}
+
+// ApplyOptions tunes the seeder.
+type ApplyOptions struct {
+ // TenantIDs receive the DefaultSystemRoles. Empty disables role seeding.
+ TenantIDs []string
+ // SkipCatalog skips the platform-wide upsert (for "tenant only" runs).
+ SkipCatalog bool
+}
+
+// Apply upserts the catalog and (optionally) the default system roles for
+// the supplied tenants. Idempotent: re-running only updates fields that
+// changed.
+func Apply(
+ ctx context.Context,
+ perms domrepo.PermissionRepository,
+ roles domrepo.RoleRepository,
+ rolePerms domrepo.RolePermissionRepository,
+ opts ApplyOptions,
+) (*Report, error) {
+ report := &Report{}
+ entries, err := LoadCatalog()
+ if err != nil {
+ return nil, err
+ }
+
+ if !opts.SkipCatalog {
+ if err := upsertCatalog(ctx, perms, entries, report); err != nil {
+ return nil, err
+ }
+ }
+
+ if len(opts.TenantIDs) == 0 {
+ return report, nil
+ }
+
+ idByName, err := loadCatalogIDIndex(ctx, perms)
+ if err != nil {
+ return nil, err
+ }
+ for _, tenantID := range opts.TenantIDs {
+ if err := seedTenantRoles(ctx, roles, rolePerms, tenantID, idByName, report); err != nil {
+ return nil, err
+ }
+ }
+ return report, nil
+}
+
+// Report holds counters returned by Apply for CLI logging.
+type Report struct {
+ CatalogUpserted int
+ RolesUpserted int
+ RolePermissionSet int
+}
+
+func upsertCatalog(
+ ctx context.Context,
+ perms domrepo.PermissionRepository,
+ entries []*CatalogEntry,
+ report *Report,
+) error {
+ now := time.Now().UTC().UnixMilli()
+
+ // First pass: name → parent name. Second pass: resolve parent name to
+ // ID after every entry is upserted.
+ parentByName := make(map[string]string, len(entries))
+ for _, entry := range entries {
+ parentByName[entry.Name] = entry.Parent
+ }
+
+ for _, entry := range entries {
+ permType := enum.PermissionType(entry.Type)
+ if permType == "" {
+ permType = enum.PermissionTypeBackendUser
+ }
+ status := enum.Status(entry.Status)
+ if status == "" {
+ status = enum.StatusOpen
+ }
+ perm := &entity.Permission{
+ Name: entry.Name,
+ HTTPMethods: entry.HTTPMethods,
+ HTTPPath: entry.HTTPPath,
+ Status: status,
+ Type: permType,
+ UpdateAt: now,
+ }
+ if err := perms.UpsertByName(ctx, perm); err != nil {
+ return fmt.Errorf("permission seed: upsert %s: %w", entry.Name, err)
+ }
+ report.CatalogUpserted++
+ }
+
+ idByName, err := loadCatalogIDIndex(ctx, perms)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ parentName, ok := parentByName[entry.Name]
+ if !ok || parentName == "" {
+ continue
+ }
+ parentID, ok := idByName[parentName]
+ if !ok {
+ return fmt.Errorf("permission seed: parent %q for %q not found", parentName, entry.Name)
+ }
+ perm := &entity.Permission{
+ Name: entry.Name,
+ Parent: parentID,
+ HTTPMethods: entry.HTTPMethods,
+ HTTPPath: entry.HTTPPath,
+ Status: enum.Status(entry.Status),
+ Type: enum.PermissionType(entry.Type),
+ UpdateAt: now,
+ }
+ if perm.Status == "" {
+ perm.Status = enum.StatusOpen
+ }
+ if perm.Type == "" {
+ perm.Type = enum.PermissionTypeBackendUser
+ }
+ if err := perms.UpsertByName(ctx, perm); err != nil {
+ return fmt.Errorf("permission seed: link parent %s: %w", entry.Name, err)
+ }
+ }
+ return nil
+}
+
+func loadCatalogIDIndex(
+ ctx context.Context,
+ perms domrepo.PermissionRepository,
+) (map[string]string, error) {
+ all, err := perms.GetAll(ctx, nil)
+ if err != nil {
+ return nil, err
+ }
+ idByName := make(map[string]string, len(all))
+ for _, p := range all {
+ idByName[p.Name] = p.ID.Hex()
+ }
+ return idByName, nil
+}
+
+func seedTenantRoles(
+ ctx context.Context,
+ roles domrepo.RoleRepository,
+ rolePerms domrepo.RolePermissionRepository,
+ tenantID string,
+ idByName map[string]string,
+ report *Report,
+) error {
+ for _, def := range DefaultSystemRoles {
+ role, err := roles.GetByKey(ctx, tenantID, def.Key)
+ if err != nil {
+ role = &entity.Role{
+ ID: bson.NewObjectID(),
+ TenantID: tenantID,
+ Key: def.Key,
+ DisplayName: def.DisplayName,
+ Status: enum.StatusOpen,
+ IsSystem: true,
+ }
+ if err := roles.Insert(ctx, role); err != nil {
+ return fmt.Errorf("permission seed: tenant=%s create role %s: %w", tenantID, def.Key, err)
+ }
+ report.RolesUpserted++
+ }
+ permissionIDs := make([]string, 0, len(def.PermissionNames))
+ for _, name := range def.PermissionNames {
+ id, ok := idByName[name]
+ if !ok {
+ return fmt.Errorf("permission seed: catalog missing %q for role %s", name, def.Key)
+ }
+ permissionIDs = append(permissionIDs, id)
+ }
+ if err := rolePerms.SetForRole(ctx, tenantID, role.ID.Hex(), permissionIDs); err != nil {
+ return fmt.Errorf("permission seed: tenant=%s set role perms %s: %w", tenantID, def.Key, err)
+ }
+ report.RolePermissionSet += len(permissionIDs)
+ }
+ return nil
+}
diff --git a/internal/model/permission/seed/catalog.json b/internal/model/permission/seed/catalog.json
new file mode 100644
index 0000000..d27983c
--- /dev/null
+++ b/internal/model/permission/seed/catalog.json
@@ -0,0 +1,197 @@
+[
+ {
+ "name": "member.info.management",
+ "type": "backend_user",
+ "description": "會員資訊管理(分類)"
+ },
+ {
+ "name": "member.basic.info",
+ "parent": "member.info.management",
+ "type": "backend_user",
+ "description": "基礎資訊(分類)"
+ },
+ {
+ "name": "member.info.select",
+ "parent": "member.basic.info",
+ "http_methods": "GET",
+ "http_path": "/api/v1/members/me",
+ "type": "backend_user",
+ "description": "讀取自身會員資料"
+ },
+ {
+ "name": "member.info.update",
+ "parent": "member.basic.info",
+ "http_methods": "PATCH",
+ "http_path": "/api/v1/members/me",
+ "type": "backend_user",
+ "description": "更新自身會員資料"
+ },
+ {
+ "name": "member.info.select.plain_code",
+ "parent": "member.info.select",
+ "http_methods": "GET",
+ "http_path": "/api/v1/members/me",
+ "type": "backend_user",
+ "description": "讀取明碼欄位(敏感)"
+ },
+ {
+ "name": "member.admin.list",
+ "parent": "member.info.management",
+ "http_methods": "GET",
+ "http_path": "/api/v1/members",
+ "type": "backend_user",
+ "description": "列出全部會員"
+ },
+ {
+ "name": "member.admin.read",
+ "parent": "member.info.management",
+ "http_methods": "GET",
+ "http_path": "/api/v1/members/:uid",
+ "type": "backend_user",
+ "description": "讀取指定會員"
+ },
+ {
+ "name": "member.admin.update",
+ "parent": "member.info.management",
+ "http_methods": "PATCH",
+ "http_path": "/api/v1/members/:uid",
+ "type": "backend_user",
+ "description": "更新指定會員"
+ },
+ {
+ "name": "member.admin.status",
+ "parent": "member.info.management",
+ "http_methods": "PATCH",
+ "http_path": "/api/v1/members/:uid/status",
+ "type": "backend_user",
+ "description": "啟停指定會員"
+ },
+
+ {
+ "name": "permission.role.management",
+ "type": "backend_user",
+ "description": "角色權限管理(分類)"
+ },
+ {
+ "name": "permission.role.read",
+ "parent": "permission.role.management",
+ "http_methods": "GET",
+ "http_path": "/api/v1/permissions/roles",
+ "type": "backend_user",
+ "description": "讀取角色清單"
+ },
+ {
+ "name": "permission.role.write",
+ "parent": "permission.role.management",
+ "http_methods": "POST|PUT|DELETE",
+ "http_path": "/api/v1/permissions/roles*",
+ "type": "backend_user",
+ "description": "管理角色(建立/修改/刪除)"
+ },
+ {
+ "name": "permission.assign.write",
+ "parent": "permission.role.management",
+ "http_methods": "POST|DELETE",
+ "http_path": "/api/v1/permissions/users/*/roles*",
+ "type": "backend_user",
+ "description": "指派 / 撤銷使用者角色"
+ },
+ {
+ "name": "permission.mapping.write",
+ "parent": "permission.role.management",
+ "http_methods": "PUT|DELETE",
+ "http_path": "/api/v1/permissions/role-mappings*",
+ "type": "backend_user",
+ "description": "管理外部角色映射"
+ },
+ {
+ "name": "permission.policy.reload",
+ "parent": "permission.role.management",
+ "http_methods": "POST",
+ "http_path": "/api/v1/permissions/policy/reload",
+ "type": "backend_user",
+ "description": "強制重載 Casbin policy"
+ },
+
+ {
+ "name": "tenant.management",
+ "type": "backend_user",
+ "description": "租戶管理(平台級)"
+ },
+ {
+ "name": "tenant.read",
+ "parent": "tenant.management",
+ "http_methods": "GET",
+ "http_path": "/api/v1/tenants*",
+ "type": "backend_user",
+ "description": "讀取租戶資訊"
+ },
+ {
+ "name": "tenant.write",
+ "parent": "tenant.management",
+ "http_methods": "POST|PATCH|DELETE",
+ "http_path": "/api/v1/tenants*",
+ "type": "backend_user",
+ "description": "管理租戶"
+ },
+
+ {
+ "name": "scim.management",
+ "type": "backend_user",
+ "description": "SCIM 同步(分類)"
+ },
+ {
+ "name": "scim.users.read",
+ "parent": "scim.management",
+ "http_methods": "GET",
+ "http_path": "/scim/v2/Users*",
+ "type": "backend_user",
+ "description": "SCIM Users 讀取"
+ },
+ {
+ "name": "scim.users.write",
+ "parent": "scim.management",
+ "http_methods": "POST|PATCH|PUT|DELETE",
+ "http_path": "/scim/v2/Users*",
+ "type": "backend_user",
+ "description": "SCIM Users 寫入"
+ },
+ {
+ "name": "scim.groups.read",
+ "parent": "scim.management",
+ "http_methods": "GET",
+ "http_path": "/scim/v2/Groups*",
+ "type": "backend_user",
+ "description": "SCIM Groups 讀取"
+ },
+ {
+ "name": "scim.groups.write",
+ "parent": "scim.management",
+ "http_methods": "POST|PATCH|PUT|DELETE",
+ "http_path": "/scim/v2/Groups*",
+ "type": "backend_user",
+ "description": "SCIM Groups 寫入"
+ },
+
+ {
+ "name": "system.management",
+ "type": "backend_user",
+ "description": "系統管理(平台級)"
+ },
+ {
+ "name": "system.audit.read",
+ "parent": "system.management",
+ "http_methods": "GET",
+ "http_path": "/api/v1/admin/audit-logs*",
+ "type": "backend_user",
+ "description": "讀取 audit log"
+ },
+ {
+ "name": "system.health.read",
+ "parent": "system.management",
+ "http_methods": "GET",
+ "http_path": "/api/v1/health",
+ "type": "backend_user",
+ "description": "健康檢查"
+ }
+]
diff --git a/internal/model/permission/usecase/authorization_query_usecase.go b/internal/model/permission/usecase/authorization_query_usecase.go
new file mode 100644
index 0000000..c84459a
--- /dev/null
+++ b/internal/model/permission/usecase/authorization_query_usecase.go
@@ -0,0 +1,120 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+)
+
+// AuthorizationQueryUseCaseParam injects all read-side repos.
+type AuthorizationQueryUseCaseParam struct {
+ Roles domrepo.RoleRepository
+ Permissions domrepo.PermissionRepository
+ RolePermissions domrepo.RolePermissionRepository
+ UserRoles domrepo.UserRoleRepository
+}
+
+type authorizationQueryUseCase struct {
+ roles domrepo.RoleRepository
+ perms domrepo.PermissionRepository
+ rolePerms domrepo.RolePermissionRepository
+ userRoles domrepo.UserRoleRepository
+}
+
+// NewAuthorizationQueryUseCase composes Role + RolePermission +
+// Permission to produce the menu/permission map used by GET /me.
+func NewAuthorizationQueryUseCase(param AuthorizationQueryUseCaseParam) dom.AuthorizationQueryUseCase {
+ return &authorizationQueryUseCase{
+ roles: param.Roles,
+ perms: param.Permissions,
+ rolePerms: param.RolePermissions,
+ userRoles: param.UserRoles,
+ }
+}
+
+func (uc *authorizationQueryUseCase) Me(
+ ctx context.Context,
+ tenantID, uid string,
+ includeTree bool,
+) (*dom.MePermissionsResponse, error) {
+ resp := &dom.MePermissionsResponse{
+ UID: uid,
+ TenantID: tenantID,
+ Roles: []string{},
+ Permissions: enum.Permissions{},
+ }
+
+ urs, err := uc.userRoles.ListByUser(ctx, tenantID, uid)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(urs) == 0 {
+ if includeTree {
+ resp.Tree = []*dom.PermissionTreeNode{}
+ }
+ return resp, nil
+ }
+
+ roleIDs := make([]string, 0, len(urs))
+ for _, ur := range urs {
+ roleIDs = append(roleIDs, ur.RoleID)
+ }
+ roles, err := uc.roles.ListByTenantAndIDs(ctx, tenantID, roleIDs)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ roleByID := make(map[string]*entity.Role, len(roles))
+ for _, role := range roles {
+ roleByID[role.ID.Hex()] = role
+ if role.Status == enum.StatusOpen {
+ resp.Roles = append(resp.Roles, role.Key)
+ }
+ }
+
+ rps, err := uc.rolePerms.ListByRoles(ctx, tenantID, roleIDs)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(rps) == 0 {
+ if includeTree {
+ resp.Tree = []*dom.PermissionTreeNode{}
+ }
+ return resp, nil
+ }
+
+ permIDSet := make(map[string]struct{}, len(rps))
+ for _, rp := range rps {
+ role, ok := roleByID[rp.RoleID]
+ if !ok || role.Status != enum.StatusOpen {
+ continue
+ }
+ permIDSet[rp.PermissionID] = struct{}{}
+ }
+ if len(permIDSet) == 0 {
+ if includeTree {
+ resp.Tree = []*dom.PermissionTreeNode{}
+ }
+ return resp, nil
+ }
+ ids := make([]string, 0, len(permIDSet))
+ for id := range permIDSet {
+ ids = append(ids, id)
+ }
+ perms, err := uc.perms.GetByIDs(ctx, ids)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ for _, perm := range perms {
+ resp.Permissions[perm.Name] = perm.Status
+ }
+
+ if includeTree {
+ resp.Tree = filterOpenNodes(buildPermissionTree(perms))
+ }
+ return resp, nil
+}
+
+var _ dom.AuthorizationQueryUseCase = (*authorizationQueryUseCase)(nil)
diff --git a/internal/model/permission/usecase/errors.go b/internal/model/permission/usecase/errors.go
new file mode 100644
index 0000000..5b976fd
--- /dev/null
+++ b/internal/model/permission/usecase/errors.go
@@ -0,0 +1,59 @@
+package usecase
+
+import (
+ "errors"
+ "strings"
+
+ errs "gateway/internal/library/errors"
+ "gateway/internal/library/errors/code"
+ permission "gateway/internal/model/permission/domain"
+)
+
+var errb = errs.For(code.Permission)
+
+// wrapRepoErr converts repository sentinel errors into structured errs
+// with the right HTTP/gRPC mapping. All usecase methods funnel repo
+// errors through this helper to keep the surface uniform.
+func wrapRepoErr(err error, msg ...string) error {
+ if err == nil {
+ return nil
+ }
+ switch {
+ case errors.Is(err, permission.ErrPermissionNotFound):
+ return errb.ResNotFound("permission not found").WithCause(err)
+ case errors.Is(err, permission.ErrPermissionDup):
+ return errb.ResAlreadyExist("permission already exists").WithCause(err)
+ case errors.Is(err, permission.ErrRoleNotFound):
+ return errb.ResNotFound("role not found").WithCause(err)
+ case errors.Is(err, permission.ErrRoleDuplicate):
+ return errb.ResAlreadyExist("role already exists in tenant").WithCause(err)
+ case errors.Is(err, permission.ErrRoleSystemImmutable):
+ return errb.ResInvalidState("system role is immutable").WithCause(err)
+ case errors.Is(err, permission.ErrRoleNotInTenant):
+ return errb.ResNotFound("role not in tenant").WithCause(err)
+ case errors.Is(err, permission.ErrRoleKeyReserved):
+ return errb.InputInvalidFormat("role key uses reserved prefix").WithCause(err)
+ case errors.Is(err, permission.ErrRoleKeyInvalid):
+ return errb.InputInvalidFormat("role key format invalid").WithCause(err)
+ case errors.Is(err, permission.ErrUserRoleNotFound):
+ return errb.ResNotFound("user role not found").WithCause(err)
+ case errors.Is(err, permission.ErrUserRoleDuplicate):
+ return errb.ResAlreadyExist("user role already assigned").WithCause(err)
+ case errors.Is(err, permission.ErrRoleMappingNotFound):
+ return errb.ResNotFound("role mapping not found").WithCause(err)
+ case errors.Is(err, permission.ErrRoleMappingDuplicate):
+ return errb.ResAlreadyExist("role mapping already exists").WithCause(err)
+ case errors.Is(err, permission.ErrCasbinNotConfigured):
+ return errb.SysNotImplemented("casbin enforcer not configured").WithCause(err)
+ case errors.Is(err, permission.ErrInvalidStatus):
+ return errb.InputInvalidFormat("invalid status").WithCause(err)
+ }
+ if e := errs.FromError(err); e != nil {
+ return err
+ }
+ m := strings.TrimSpace(strings.Join(msg, " "))
+ if m == "" {
+ m = "permission repository error"
+ }
+ return errb.DBError(m).WithCause(err)
+}
diff --git a/internal/model/permission/usecase/module.go b/internal/model/permission/usecase/module.go
new file mode 100644
index 0000000..14f4258
--- /dev/null
+++ b/internal/model/permission/usecase/module.go
@@ -0,0 +1,166 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ libmongo "gateway/internal/library/mongo"
+ redislib "gateway/internal/library/redis"
+ permcfg "gateway/internal/model/permission/config"
+ permission "gateway/internal/model/permission/domain"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+ permrepo "gateway/internal/model/permission/repository"
+)
+
+// FactoryParam configures the permission module. Repositories may be
+// pre-built (used by tests / cmd seed) or auto-constructed from MongoConf.
+type FactoryParam struct {
+ MongoConf *libmongo.Conf
+ Redis *redislib.Client
+ Config permcfg.Config
+
+ // Optional pre-built repositories. When set, MongoConf is ignored
+ // for that repository.
+ Permissions domrepo.PermissionRepository
+ Roles domrepo.RoleRepository
+ RolePermissions domrepo.RolePermissionRepository
+ UserRoles domrepo.UserRoleRepository
+ RoleMappings domrepo.RoleMappingRepository
+
+ // Optional Casbin model text (overrides Config.Casbin.ModelPath).
+ CasbinModelText string
+}
+
+// Module bundles all permission usecase ports.
+type Module struct {
+ Permission dom.PermissionUseCase
+ Role dom.RoleUseCase
+ RolePermission dom.RolePermissionUseCase
+ UserRole dom.UserRoleUseCase
+ RoleMapping dom.RoleMappingUseCase
+ AuthorizationQuery dom.AuthorizationQueryUseCase
+ RBAC dom.RBACUseCase
+
+ Permissions domrepo.PermissionRepository
+ Roles domrepo.RoleRepository
+ RolePermissions domrepo.RolePermissionRepository
+ UserRoles domrepo.UserRoleRepository
+ RoleMappings domrepo.RoleMappingRepository
+}
+
+// NewModuleFromParam wires the seven usecases against the configured
+// repositories. Mongo is required for catalog/role/user-role/mapping;
+// Redis is required for the Casbin enforcer + pub/sub broadcast.
+//
+// When Redis is missing, RBAC stays nil and Permission/Role mutations
+// continue to work but Check() always denies. Mongo missing returns an
+// error because the catalog cannot live anywhere else.
+func NewModuleFromParam(param FactoryParam) (*Module, error) {
+ cfg := param.Config.Defaults()
+ mod := &Module{
+ Permissions: param.Permissions,
+ Roles: param.Roles,
+ RolePermissions: param.RolePermissions,
+ UserRoles: param.UserRoles,
+ RoleMappings: param.RoleMappings,
+ }
+
+ if mod.Permissions == nil {
+ if param.MongoConf == nil || param.MongoConf.Host == "" {
+ return nil, fmt.Errorf("permission: mongo config required")
+ }
+ mod.Permissions = permrepo.NewPermissionRepository(permrepo.PermissionRepositoryParam{Conf: param.MongoConf})
+ }
+ if mod.Roles == nil {
+ mod.Roles = permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: param.MongoConf})
+ }
+ if mod.RolePermissions == nil {
+ mod.RolePermissions = permrepo.NewRolePermissionRepository(permrepo.RolePermissionRepositoryParam{Conf: param.MongoConf})
+ }
+ if mod.UserRoles == nil {
+ mod.UserRoles = permrepo.NewUserRoleRepository(permrepo.UserRoleRepositoryParam{Conf: param.MongoConf})
+ }
+ if mod.RoleMappings == nil {
+ mod.RoleMappings = permrepo.NewRoleMappingRepository(permrepo.RoleMappingRepositoryParam{Conf: param.MongoConf})
+ }
+
+ mod.Permission = NewPermissionUseCase(PermissionUseCaseParam{Permissions: mod.Permissions})
+
+ var reloader PolicyReloader
+ if cfg.Casbin.Enabled && param.Redis != nil && param.Redis.Zero() != nil {
+ // Plug the bridge so rbac_usecase can build a Redis adapter
+ // without importing repository (avoids cycle).
+ RedisAdapterFactory = func(client *redislib.Client) (domrepo.CasbinPolicyAdapter, error) {
+ if client == nil || client.Zero() == nil {
+ return nil, nil
+ }
+ return permrepo.NewCasbinRedisAdapter(client)
+ }
+ rbacUC, err := NewRBACUseCase(RBACUseCaseParam{
+ Roles: mod.Roles,
+ Permissions: mod.Permissions,
+ RolePermissions: mod.RolePermissions,
+ UserRoles: mod.UserRoles,
+ Redis: param.Redis,
+ ModelPath: cfg.Casbin.ModelPath,
+ CasbinModelText: param.CasbinModelText,
+ ReloadChannel: cfg.Reload.Channel,
+ })
+ if err != nil && !errors.Is(err, permission.ErrCasbinNotConfigured) {
+ return nil, err
+ }
+ mod.RBAC = rbacUC
+ if rbacUC != nil {
+ reloader = rbacUC.BroadcastReload
+ }
+ }
+
+ mod.Role = NewRoleUseCase(RoleUseCaseParam{
+ Roles: mod.Roles,
+ RolePermissions: mod.RolePermissions,
+ UserRoles: mod.UserRoles,
+ })
+ mod.RolePermission = NewRolePermissionUseCase(RolePermissionUseCaseParam{
+ Roles: mod.Roles,
+ Permissions: mod.Permissions,
+ RolePermissions: mod.RolePermissions,
+ Reloader: reloader,
+ })
+ mod.UserRole = NewUserRoleUseCase(UserRoleUseCaseParam{
+ Roles: mod.Roles,
+ UserRoles: mod.UserRoles,
+ Reloader: reloader,
+ })
+ mod.RoleMapping = NewRoleMappingUseCase(RoleMappingUseCaseParam{
+ Roles: mod.Roles,
+ Mappings: mod.RoleMappings,
+ })
+ mod.AuthorizationQuery = NewAuthorizationQueryUseCase(AuthorizationQueryUseCaseParam{
+ Roles: mod.Roles,
+ Permissions: mod.Permissions,
+ RolePermissions: mod.RolePermissions,
+ UserRoles: mod.UserRoles,
+ })
+
+ return mod, nil
+}
+
+// StartBackground starts the policy reload subscriber when configured.
+// Safe to call when RBAC is nil (no-op).
+func (m *Module) StartBackground(ctx context.Context) error {
+ if m == nil || m.RBAC == nil {
+ return nil
+ }
+ return m.RBAC.StartReloadSubscriber(ctx)
+}
+
+// StopBackground tears down the subscriber. Safe to call when never
+// started.
+func (m *Module) StopBackground() {
+ if m == nil || m.RBAC == nil {
+ return
+ }
+ m.RBAC.StopReloadSubscriber()
+}
diff --git a/internal/model/permission/usecase/permission_tree.go b/internal/model/permission/usecase/permission_tree.go
new file mode 100644
index 0000000..92066bf
--- /dev/null
+++ b/internal/model/permission/usecase/permission_tree.go
@@ -0,0 +1,121 @@
+package usecase
+
+import (
+ "sort"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ dom "gateway/internal/model/permission/domain/usecase"
+)
+
+// buildPermissionTree converts the flat permission list into a parent →
+// children tree. Roots (parent == "") are returned in alphabetical order
+// by Name for stable client rendering.
+func buildPermissionTree(perms []*entity.Permission) []*dom.PermissionTreeNode {
+ byParent := make(map[string][]*dom.PermissionTreeNode)
+ indexByID := make(map[string]*dom.PermissionTreeNode, len(perms))
+ for _, perm := range perms {
+ node := permissionToNode(perm)
+ indexByID[node.ID] = node
+ byParent[perm.Parent] = append(byParent[perm.Parent], node)
+ }
+ for _, children := range byParent {
+ sort.SliceStable(children, func(i, j int) bool {
+ return children[i].Name < children[j].Name
+ })
+ }
+ for parentID, children := range byParent {
+ if parent, ok := indexByID[parentID]; ok {
+ parent.Children = children
+ }
+ }
+ return byParent[""]
+}
+
+// filterOpenNodes prunes status=close subtrees (and any parent that has
+// no remaining children). Mirrors permission-server's filterOpenNodes.
+func filterOpenNodes(nodes []*dom.PermissionTreeNode) []*dom.PermissionTreeNode {
+ out := make([]*dom.PermissionTreeNode, 0, len(nodes))
+ for _, node := range nodes {
+ if node.Status != enum.StatusOpen {
+ continue
+ }
+ if len(node.Children) > 0 {
+ node.Children = filterOpenNodes(node.Children)
+ }
+ out = append(out, node)
+ }
+ return out
+}
+
+// filterByType drops subtrees whose type does not match. Useful for the
+// "frontend menu only" client query.
+func filterByType(nodes []*dom.PermissionTreeNode, t enum.PermissionType) []*dom.PermissionTreeNode {
+ out := make([]*dom.PermissionTreeNode, 0, len(nodes))
+ for _, node := range nodes {
+ // Category nodes inherit the type of their children when they
+ // have no leaf type of their own; filter recursively first.
+ var kids []*dom.PermissionTreeNode
+ if len(node.Children) > 0 {
+ kids = filterByType(node.Children, t)
+ }
+ if node.Type == t || len(kids) > 0 {
+ node.Children = kids
+ out = append(out, node)
+ }
+ }
+ return out
+}
+
+// getFullParentPermissionIDs walks up the parent chain for each id in
+// requestedIDs and returns the deduplicated closure (requested + every
+// ancestor). Mirrors permission-server's helper of the same name; used
+// when persisting RolePermissions so a tenant prefix-clicking a leaf also
+// gets the parent UI sections.
+func getFullParentPermissionIDs(
+ requestedIDs []string,
+ allPermissions []*entity.Permission,
+) []string {
+ parentByID := make(map[string]string, len(allPermissions))
+ for _, perm := range allPermissions {
+ parentByID[perm.ID.Hex()] = perm.Parent
+ }
+ seen := make(map[string]struct{}, len(requestedIDs)*2)
+ out := make([]string, 0, len(requestedIDs)*2)
+ for _, id := range requestedIDs {
+ walkParents(id, parentByID, seen, &out)
+ }
+ return out
+}
+
+func walkParents(
+ id string,
+ parentByID map[string]string,
+ seen map[string]struct{},
+ out *[]string,
+) {
+ for id != "" {
+ if _, ok := seen[id]; ok {
+ return
+ }
+ seen[id] = struct{}{}
+ *out = append(*out, id)
+ parent, ok := parentByID[id]
+ if !ok || parent == "" {
+ return
+ }
+ id = parent
+ }
+}
+
+func permissionToNode(perm *entity.Permission) *dom.PermissionTreeNode {
+ return &dom.PermissionTreeNode{
+ ID: perm.ID.Hex(),
+ Parent: perm.Parent,
+ Name: perm.Name,
+ HTTPMethods: perm.HTTPMethods,
+ HTTPPath: perm.HTTPPath,
+ Status: perm.Status,
+ Type: perm.Type,
+ }
+}
diff --git a/internal/model/permission/usecase/permission_usecase.go b/internal/model/permission/usecase/permission_usecase.go
new file mode 100644
index 0000000..eaca918
--- /dev/null
+++ b/internal/model/permission/usecase/permission_usecase.go
@@ -0,0 +1,110 @@
+// Package usecase implements the permission module's domain interfaces.
+// Use NewModuleFromParam (module.go) to wire all seven usecases against a
+// shared Mongo + Redis backend.
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+)
+
+// PermissionUseCaseParam injects the catalog repository.
+type PermissionUseCaseParam struct {
+ Permissions domrepo.PermissionRepository
+}
+
+type permissionUseCase struct {
+ repo domrepo.PermissionRepository
+}
+
+// NewPermissionUseCase returns the catalog-facing usecase.
+func NewPermissionUseCase(param PermissionUseCaseParam) dom.PermissionUseCase {
+ return &permissionUseCase{repo: param.Permissions}
+}
+
+func (uc *permissionUseCase) GetCatalogTree(
+ ctx context.Context,
+ query *dom.CatalogQuery,
+) ([]*dom.PermissionTreeNode, error) {
+ perms, err := uc.repo.GetAll(ctx, nil)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ tree := buildPermissionTree(perms)
+ if query == nil {
+ return tree, nil
+ }
+ if query.OnlyOpen {
+ tree = filterOpenNodes(tree)
+ }
+ if query.Type != nil {
+ tree = filterByType(tree, *query.Type)
+ }
+ return tree, nil
+}
+
+func (uc *permissionUseCase) List(ctx context.Context, query *dom.CatalogQuery) ([]*entity.Permission, error) {
+ var status *enum.Status
+ if query != nil && query.OnlyOpen {
+ open := enum.StatusOpen
+ status = &open
+ }
+ perms, err := uc.repo.GetAll(ctx, status)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if query != nil && query.Type != nil {
+ filtered := perms[:0]
+ for _, p := range perms {
+ if p.Type == *query.Type {
+ filtered = append(filtered, p)
+ }
+ }
+ perms = filtered
+ }
+ return perms, nil
+}
+
+func (uc *permissionUseCase) GetByID(ctx context.Context, id string) (*entity.Permission, error) {
+ perm, err := uc.repo.GetByID(ctx, id)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return perm, nil
+}
+
+func (uc *permissionUseCase) GetByName(ctx context.Context, name string) (*entity.Permission, error) {
+ perm, err := uc.repo.GetByName(ctx, name)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return perm, nil
+}
+
+func (uc *permissionUseCase) UpsertCatalog(ctx context.Context, perms []*entity.Permission) error {
+ for _, perm := range perms {
+ if perm.Status == "" {
+ perm.Status = enum.StatusOpen
+ }
+ if err := uc.repo.UpsertByName(ctx, perm); err != nil {
+ return wrapRepoErr(err, "upsert catalog")
+ }
+ }
+ return nil
+}
+
+func (uc *permissionUseCase) UpdateStatus(ctx context.Context, id string, status enum.Status) error {
+ if !status.IsValid() {
+ return errb.InputInvalidFormat("invalid status").WithCause(nil)
+ }
+ if err := uc.repo.UpdateStatus(ctx, id, status); err != nil {
+ return wrapRepoErr(err, "update status")
+ }
+ return nil
+}
+
+var _ dom.PermissionUseCase = (*permissionUseCase)(nil)
diff --git a/internal/model/permission/usecase/rbac_usecase.go b/internal/model/permission/usecase/rbac_usecase.go
new file mode 100644
index 0000000..21709de
--- /dev/null
+++ b/internal/model/permission/usecase/rbac_usecase.go
@@ -0,0 +1,427 @@
+package usecase
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ redislib "gateway/internal/library/redis"
+ permission "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+
+ "github.com/casbin/casbin/v2"
+ casbinmodel "github.com/casbin/casbin/v2/model"
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+// RBACUseCaseParam injects all repos + Redis Pub/Sub client. ModelPath
+// must point at etc/rbac.conf; CasbinModelText overrides ModelPath when
+// non-empty (used by tests / embedded resources).
+type RBACUseCaseParam struct {
+ Roles domrepo.RoleRepository
+ Permissions domrepo.PermissionRepository
+ RolePermissions domrepo.RolePermissionRepository
+ UserRoles domrepo.UserRoleRepository
+ Redis *redislib.Client
+ ModelPath string
+ CasbinModelText string
+ ReloadChannel string
+}
+
+// reloadEvent is the JSON payload published on the reload channel.
+type reloadEvent struct {
+ TenantID string `json:"tenant_id"`
+ TS int64 `json:"ts"`
+}
+
+type rbacUseCase struct {
+ roles domrepo.RoleRepository
+ perms domrepo.PermissionRepository
+ rolePerms domrepo.RolePermissionRepository
+ userRoles domrepo.UserRoleRepository
+ redis *redislib.Client
+
+ enforcerMu sync.RWMutex
+ enforcers map[string]*casbin.SyncedEnforcer
+
+ model casbinmodel.Model
+ modelMu sync.Mutex
+ modelTxt string
+
+ reloadChannel string
+ stopSubscribe context.CancelFunc
+ stopMu sync.Mutex
+}
+
+// NewRBACUseCase wires the Casbin enforcer with the persistence layer.
+// Returns ErrCasbinNotConfigured when Redis is missing — Casbin's Redis
+// adapter and Pub/Sub require Redis to function.
+func NewRBACUseCase(param RBACUseCaseParam) (dom.RBACUseCase, error) {
+ if param.Redis == nil || param.Redis.Zero() == nil {
+ return nil, permission.ErrCasbinNotConfigured
+ }
+ channel := strings.TrimSpace(param.ReloadChannel)
+ if channel == "" {
+ channel = permission.PolicyReloadChannel
+ }
+ uc := &rbacUseCase{
+ roles: param.Roles,
+ perms: param.Permissions,
+ rolePerms: param.RolePermissions,
+ userRoles: param.UserRoles,
+ redis: param.Redis,
+ enforcers: make(map[string]*casbin.SyncedEnforcer),
+ modelTxt: strings.TrimSpace(param.CasbinModelText),
+ reloadChannel: channel,
+ }
+ if uc.modelTxt == "" && param.ModelPath != "" {
+ mdl, err := casbinmodel.NewModelFromFile(param.ModelPath)
+ if err != nil {
+ return nil, fmt.Errorf("permission: load casbin model: %w", err)
+ }
+ uc.model = mdl
+ }
+ return uc, nil
+}
+
+// Check enforces (tenant, uid → role keys) ∩ policy. Multiple roles use
+// any-allow semantics: the first matching role short-circuits with
+// allow=true. The `r.role == p.role` matcher means we must call EnforceEx
+// once per role; that is acceptable because a member typically has 1–3
+// roles and the call is in-memory.
+func (uc *rbacUseCase) Check(ctx context.Context, req *dom.CheckRequest) (*dom.CheckResult, error) {
+ if req == nil || req.TenantID == "" || req.UID == "" || req.Path == "" || req.Method == "" {
+ return nil, permission.ErrInvalidCheckRequest
+ }
+ enforcer, err := uc.enforcerFor(ctx, req.TenantID)
+ if err != nil {
+ return nil, err
+ }
+ roleKeys, err := uc.roleKeysOf(ctx, req.TenantID, req.UID)
+ if err != nil {
+ return nil, err
+ }
+ if len(roleKeys) == 0 {
+ return &dom.CheckResult{Allow: false}, nil
+ }
+ for _, key := range roleKeys {
+ ok, matched, err := enforcer.EnforceEx(req.TenantID, key, req.Path, req.Method)
+ if err != nil {
+ return nil, fmt.Errorf("permission: enforce: %w", err)
+ }
+ if ok {
+ return &dom.CheckResult{
+ Allow: true,
+ MatchedRoleKey: key,
+ MatchedPolicyRow: append([]string{permission.CasbinPolicyType}, matched...),
+ }, nil
+ }
+ }
+ return &dom.CheckResult{Allow: false}, nil
+}
+
+// LoadPolicy materialises role_permissions for a single tenant into
+// Casbin policy rules and atomically saves them via the Redis adapter.
+func (uc *rbacUseCase) LoadPolicy(ctx context.Context, tenantID string) error {
+ rules, err := uc.buildRules(ctx, tenantID)
+ if err != nil {
+ return err
+ }
+ enforcer, err := uc.enforcerFor(ctx, tenantID)
+ if err != nil {
+ return err
+ }
+ enforcer.ClearPolicy()
+ if len(rules) > 0 {
+ if _, err := enforcer.AddPolicies(rules); err != nil {
+ return fmt.Errorf("permission: add policies: %w", err)
+ }
+ }
+ if err := uc.saveAdapter(ctx, tenantID, rules); err != nil {
+ logx.WithContext(ctx).Errorf("permission: save adapter tenant=%s: %v", tenantID, err)
+ }
+ return nil
+}
+
+// LoadAllPolicies refreshes policies for every tenant. Used by the
+// 5-minute cron fallback (see plan §6.11).
+func (uc *rbacUseCase) LoadAllPolicies(ctx context.Context) error {
+ // Tenant list comes from the member module via Casbin keys; here we
+ // scan the role collection's distinct tenant_id. For simplicity we
+ // reload only tenants that have at least one role.
+ roles, err := uc.allTenantsWithRoles(ctx)
+ if err != nil {
+ return err
+ }
+ for _, tenantID := range roles {
+ if err := uc.LoadPolicy(ctx, tenantID); err != nil {
+ logx.WithContext(ctx).Errorf("permission: reload tenant=%s: %v", tenantID, err)
+ }
+ }
+ return nil
+}
+
+// BroadcastReload publishes a tenant-scoped reload event over Redis
+// Pub/Sub. Other pods (and this pod itself) consume it to re-LoadPolicy.
+func (uc *rbacUseCase) BroadcastReload(ctx context.Context, tenantID string) error {
+ if uc.redis == nil || uc.redis.Zero() == nil {
+ return nil
+ }
+ if tenantID == "" {
+ tenantID = permission.PolicyReloadAllToken
+ }
+ payload, err := json.Marshal(reloadEvent{TenantID: tenantID, TS: time.Now().UnixMilli()})
+ if err != nil {
+ return err
+ }
+ _, err = uc.redis.Zero().PublishCtx(ctx, uc.reloadChannel, string(payload))
+ return err
+}
+
+// StartReloadSubscriber spins a goroutine that reads from the Redis
+// Pub/Sub channel and calls LoadPolicy for each event. Idempotent: a
+// second call replaces the prior subscription.
+func (uc *rbacUseCase) StartReloadSubscriber(ctx context.Context) error {
+ uc.StopReloadSubscriber()
+ pubsub := uc.redis.PubSubClient()
+ if pubsub == nil {
+ return nil
+ }
+ subCtx, cancel := context.WithCancel(ctx)
+ uc.stopMu.Lock()
+ uc.stopSubscribe = cancel
+ uc.stopMu.Unlock()
+
+ sub := pubsub.Subscribe(subCtx, uc.reloadChannel)
+ if _, err := sub.Receive(subCtx); err != nil {
+ cancel()
+ return fmt.Errorf("permission: subscribe reload channel: %w", err)
+ }
+ ch := sub.Channel()
+ go func() {
+ defer func() { _ = sub.Close() }()
+ for {
+ select {
+ case <-subCtx.Done():
+ return
+ case msg, ok := <-ch:
+ if !ok {
+ return
+ }
+ uc.handleReload(subCtx, msg.Payload)
+ }
+ }
+ }()
+ return nil
+}
+
+// StopReloadSubscriber cancels the subscriber goroutine (best-effort).
+func (uc *rbacUseCase) StopReloadSubscriber() {
+ uc.stopMu.Lock()
+ defer uc.stopMu.Unlock()
+ if uc.stopSubscribe != nil {
+ uc.stopSubscribe()
+ uc.stopSubscribe = nil
+ }
+}
+
+func (uc *rbacUseCase) handleReload(ctx context.Context, payload string) {
+ var ev reloadEvent
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
+ logx.WithContext(ctx).Errorf("permission: invalid reload payload: %s", payload)
+ return
+ }
+ if ev.TenantID == permission.PolicyReloadAllToken || ev.TenantID == "" {
+ if err := uc.LoadAllPolicies(ctx); err != nil {
+ logx.WithContext(ctx).Errorf("permission: reload all: %v", err)
+ }
+ return
+ }
+ if err := uc.LoadPolicy(ctx, ev.TenantID); err != nil {
+ logx.WithContext(ctx).Errorf("permission: reload tenant=%s: %v", ev.TenantID, err)
+ }
+}
+
+func (uc *rbacUseCase) enforcerFor(ctx context.Context, tenantID string) (*casbin.SyncedEnforcer, error) {
+ uc.enforcerMu.RLock()
+ if e, ok := uc.enforcers[tenantID]; ok {
+ uc.enforcerMu.RUnlock()
+ return e, nil
+ }
+ uc.enforcerMu.RUnlock()
+
+ uc.enforcerMu.Lock()
+ defer uc.enforcerMu.Unlock()
+ if e, ok := uc.enforcers[tenantID]; ok {
+ return e, nil
+ }
+ mdl, err := uc.cloneModel()
+ if err != nil {
+ return nil, err
+ }
+ enforcer, err := casbin.NewSyncedEnforcer(mdl)
+ if err != nil {
+ return nil, fmt.Errorf("permission: new enforcer: %w", err)
+ }
+ enforcer.EnableAutoSave(false)
+ uc.enforcers[tenantID] = enforcer
+
+ rules, err := uc.buildRules(ctx, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ if len(rules) > 0 {
+ if _, err := enforcer.AddPolicies(rules); err != nil {
+ return nil, fmt.Errorf("permission: seed policies: %w", err)
+ }
+ }
+ return enforcer, nil
+}
+
+func (uc *rbacUseCase) cloneModel() (casbinmodel.Model, error) {
+ uc.modelMu.Lock()
+ defer uc.modelMu.Unlock()
+ if uc.modelTxt != "" {
+ return casbinmodel.NewModelFromString(uc.modelTxt)
+ }
+ if uc.model == nil {
+ return nil, errors.New("permission: casbin model not loaded")
+ }
+ // casbin/model is not safe for concurrent enforcers in some versions;
+ // dump+parse keeps each enforcer isolated.
+ return casbinmodel.NewModelFromString(uc.model.ToText())
+}
+
+func (uc *rbacUseCase) buildRules(ctx context.Context, tenantID string) ([][]string, error) {
+ roles, err := uc.roles.ListByTenant(ctx, tenantID)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(roles) == 0 {
+ return nil, nil
+ }
+ roleByID := make(map[string]*entity.Role, len(roles))
+ roleIDs := make([]string, 0, len(roles))
+ for _, role := range roles {
+ if role.Status != enum.StatusOpen {
+ continue
+ }
+ roleByID[role.ID.Hex()] = role
+ roleIDs = append(roleIDs, role.ID.Hex())
+ }
+ rps, err := uc.rolePerms.ListByRoles(ctx, tenantID, roleIDs)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(rps) == 0 {
+ return nil, nil
+ }
+ permIDSet := make(map[string]struct{}, len(rps))
+ for _, rp := range rps {
+ permIDSet[rp.PermissionID] = struct{}{}
+ }
+ ids := make([]string, 0, len(permIDSet))
+ for id := range permIDSet {
+ ids = append(ids, id)
+ }
+ perms, err := uc.perms.GetByIDs(ctx, ids)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ permByID := make(map[string]*entity.Permission, len(perms))
+ for _, perm := range perms {
+ permByID[perm.ID.Hex()] = perm
+ }
+ rules := make([][]string, 0, len(rps))
+ for _, rp := range rps {
+ role, ok := roleByID[rp.RoleID]
+ if !ok {
+ continue
+ }
+ perm, ok := permByID[rp.PermissionID]
+ if !ok || !perm.IsLeaf() || perm.Status != enum.StatusOpen {
+ continue
+ }
+ rules = append(rules, []string{
+ tenantID,
+ role.Key,
+ perm.HTTPPath,
+ perm.HTTPMethods,
+ perm.Name,
+ })
+ }
+ return rules, nil
+}
+
+func (uc *rbacUseCase) allTenantsWithRoles(ctx context.Context) ([]string, error) {
+ // Casbin reload is best-effort across pods; we use the Redis cluster
+ // to remember which tenant keys exist. Empty set ⇒ nothing to do.
+ if uc.redis == nil || uc.redis.Zero() == nil {
+ return nil, nil
+ }
+ keys, err := uc.redis.Zero().KeysCtx(ctx, permission.CasbinRulesRedisKey.String()+":*")
+ if err != nil {
+ return nil, err
+ }
+ prefix := permission.CasbinRulesRedisKey.String() + ":"
+ tenantIDs := make([]string, 0, len(keys))
+ for _, key := range keys {
+ tenantIDs = append(tenantIDs, strings.TrimPrefix(key, prefix))
+ }
+ return tenantIDs, nil
+}
+
+func (uc *rbacUseCase) saveAdapter(ctx context.Context, tenantID string, rules [][]string) error {
+ adapter, err := newRedisAdapterFromClient(uc.redis)
+ if err != nil || adapter == nil {
+ return err
+ }
+ return adapter.SaveAll(ctx, tenantID, rules)
+}
+
+// newRedisAdapterFromClient is implemented in casbin_adapter_bridge.go to
+// keep the import surface narrow (avoid pulling repository into usecase).
+func newRedisAdapterFromClient(client *redislib.Client) (domrepo.CasbinPolicyAdapter, error) {
+ return RedisAdapterFactory(client)
+}
+
+// RedisAdapterFactory is plugged in by module.go (DI seam). Tests can
+// override by assigning a stub.
+var RedisAdapterFactory = func(_ *redislib.Client) (domrepo.CasbinPolicyAdapter, error) {
+ return nil, nil
+}
+
+func (uc *rbacUseCase) roleKeysOf(ctx context.Context, tenantID, uid string) ([]string, error) {
+ urs, err := uc.userRoles.ListByUser(ctx, tenantID, uid)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(urs) == 0 {
+ return nil, nil
+ }
+ roleIDs := make([]string, 0, len(urs))
+ for _, ur := range urs {
+ roleIDs = append(roleIDs, ur.RoleID)
+ }
+ roles, err := uc.roles.ListByTenantAndIDs(ctx, tenantID, roleIDs)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ out := make([]string, 0, len(roles))
+ for _, role := range roles {
+ if role.Status != enum.StatusOpen {
+ continue
+ }
+ out = append(out, role.Key)
+ }
+ return out, nil
+}
+
+var _ dom.RBACUseCase = (*rbacUseCase)(nil)
diff --git a/internal/model/permission/usecase/role_mapping_usecase.go b/internal/model/permission/usecase/role_mapping_usecase.go
new file mode 100644
index 0000000..7ca8193
--- /dev/null
+++ b/internal/model/permission/usecase/role_mapping_usecase.go
@@ -0,0 +1,120 @@
+package usecase
+
+import (
+ "context"
+ "strings"
+
+ "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+)
+
+// RoleMappingUseCaseParam injects mapping + role repositories.
+type RoleMappingUseCaseParam struct {
+ Roles domrepo.RoleRepository
+ Mappings domrepo.RoleMappingRepository
+}
+
+type roleMappingUseCase struct {
+ roles domrepo.RoleRepository
+ mappings domrepo.RoleMappingRepository
+}
+
+// NewRoleMappingUseCase returns the external→internal mapping editor.
+func NewRoleMappingUseCase(param RoleMappingUseCaseParam) dom.RoleMappingUseCase {
+ return &roleMappingUseCase{
+ roles: param.Roles,
+ mappings: param.Mappings,
+ }
+}
+
+func (uc *roleMappingUseCase) Upsert(
+ ctx context.Context,
+ param *dom.UpsertMappingParam,
+) (*entity.RoleMapping, error) {
+ if param == nil ||
+ param.TenantID == "" ||
+ param.ExternalKey == "" ||
+ param.InternalRoleKey == "" {
+ return nil, errb.InputMissingRequired("tenant_id|external_key|internal_role_key")
+ }
+ if !param.ExternalSource.IsValid() {
+ return nil, errb.InputInvalidFormat("invalid external_source")
+ }
+ if param.ExternalSource == enum.RoleSourceManual {
+ return nil, errb.InputInvalidFormat("manual source not allowed for mapping")
+ }
+ role, err := uc.roles.GetByKey(ctx, param.TenantID, param.InternalRoleKey)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ rm := &entity.RoleMapping{
+ TenantID: param.TenantID,
+ ExternalSource: param.ExternalSource,
+ ExternalKey: strings.TrimSpace(param.ExternalKey),
+ InternalRoleID: role.ID.Hex(),
+ InternalRoleKey: role.Key,
+ }
+ if err := uc.mappings.Upsert(ctx, rm); err != nil {
+ return nil, wrapRepoErr(err, "upsert mapping")
+ }
+ return rm, nil
+}
+
+func (uc *roleMappingUseCase) Delete(
+ ctx context.Context,
+ tenantID string,
+ source enum.RoleSource,
+ externalKey string,
+) error {
+ if !source.IsValid() {
+ return errb.InputInvalidFormat("invalid external_source")
+ }
+ if err := uc.mappings.Delete(ctx, tenantID, source, externalKey); err != nil {
+ return wrapRepoErr(err, "delete mapping")
+ }
+ return nil
+}
+
+func (uc *roleMappingUseCase) GetByExternal(
+ ctx context.Context,
+ tenantID string,
+ source enum.RoleSource,
+ externalKey string,
+) (*entity.RoleMapping, error) {
+ rm, err := uc.mappings.GetByExternal(ctx, tenantID, source, externalKey)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return rm, nil
+}
+
+func (uc *roleMappingUseCase) List(
+ ctx context.Context,
+ tenantID string,
+ query *dom.ListMappingQuery,
+) ([]*entity.RoleMapping, int64, error) {
+ var source *enum.RoleSource
+ var offset int64
+ limit := int64(domain.RoleMappingPageSize)
+ if query != nil {
+ if query.Source != nil {
+ source = query.Source
+ }
+ if query.Offset > 0 {
+ offset = query.Offset
+ }
+ if query.Limit > 0 {
+ limit = query.Limit
+ }
+ }
+ docs, total, err := uc.mappings.ListByTenant(ctx, tenantID, source, offset, limit)
+ if err != nil {
+ return nil, 0, wrapRepoErr(err)
+ }
+ return docs, total, nil
+}
+
+var _ dom.RoleMappingUseCase = (*roleMappingUseCase)(nil)
diff --git a/internal/model/permission/usecase/role_permission_usecase.go b/internal/model/permission/usecase/role_permission_usecase.go
new file mode 100644
index 0000000..eed96cc
--- /dev/null
+++ b/internal/model/permission/usecase/role_permission_usecase.go
@@ -0,0 +1,101 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain/entity"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+)
+
+// PolicyReloader is the optional callback invoked after Replace mutates
+// role_permissions. RBACUseCase.BroadcastReload satisfies it; passing nil
+// disables the post-replace broadcast (useful in tests).
+type PolicyReloader func(ctx context.Context, tenantID string) error
+
+// RolePermissionUseCaseParam injects all repos needed to replace role
+// permission sets and the reloader hook.
+type RolePermissionUseCaseParam struct {
+ Roles domrepo.RoleRepository
+ Permissions domrepo.PermissionRepository
+ RolePermissions domrepo.RolePermissionRepository
+ Reloader PolicyReloader
+}
+
+type rolePermissionUseCase struct {
+ roles domrepo.RoleRepository
+ perms domrepo.PermissionRepository
+ rolePerms domrepo.RolePermissionRepository
+ reload PolicyReloader
+}
+
+// NewRolePermissionUseCase returns the role↔permission editor.
+func NewRolePermissionUseCase(param RolePermissionUseCaseParam) dom.RolePermissionUseCase {
+ return &rolePermissionUseCase{
+ roles: param.Roles,
+ perms: param.Permissions,
+ rolePerms: param.RolePermissions,
+ reload: param.Reloader,
+ }
+}
+
+func (uc *rolePermissionUseCase) List(
+ ctx context.Context,
+ tenantID, roleID string,
+) ([]*entity.Permission, error) {
+ if _, err := uc.roles.GetByID(ctx, tenantID, roleID); err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ rps, err := uc.rolePerms.ListByRole(ctx, tenantID, roleID)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(rps) == 0 {
+ return nil, nil
+ }
+ ids := make([]string, 0, len(rps))
+ for _, rp := range rps {
+ ids = append(ids, rp.PermissionID)
+ }
+ perms, err := uc.perms.GetByIDs(ctx, ids)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return perms, nil
+}
+
+func (uc *rolePermissionUseCase) Replace(
+ ctx context.Context,
+ tenantID, roleID string,
+ permissionIDs []string,
+) error {
+ role, err := uc.roles.GetByID(ctx, tenantID, roleID)
+ if err != nil {
+ return wrapRepoErr(err)
+ }
+ allPerms, err := uc.perms.GetAll(ctx, nil)
+ if err != nil {
+ return wrapRepoErr(err)
+ }
+ allowed := make(map[string]struct{}, len(allPerms))
+ for _, perm := range allPerms {
+ allowed[perm.ID.Hex()] = struct{}{}
+ }
+ for _, id := range permissionIDs {
+ if _, ok := allowed[id]; !ok {
+ return errb.ResNotFound("permission not in catalog: " + id)
+ }
+ }
+ closure := getFullParentPermissionIDs(permissionIDs, allPerms)
+ if err := uc.rolePerms.SetForRole(ctx, tenantID, roleID, closure); err != nil {
+ return wrapRepoErr(err, "set role permissions")
+ }
+ if uc.reload != nil {
+ if err := uc.reload(ctx, role.TenantID); err != nil {
+ return wrapRepoErr(err, "reload policy")
+ }
+ }
+ return nil
+}
+
+var _ dom.RolePermissionUseCase = (*rolePermissionUseCase)(nil)
diff --git a/internal/model/permission/usecase/role_usecase.go b/internal/model/permission/usecase/role_usecase.go
new file mode 100644
index 0000000..0b342cb
--- /dev/null
+++ b/internal/model/permission/usecase/role_usecase.go
@@ -0,0 +1,183 @@
+package usecase
+
+import (
+ "context"
+ "regexp"
+ "strings"
+
+ "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+)
+
+// roleKeyPattern enforces lower-case alphanumeric / underscore / dot keys.
+var roleKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,63}$`)
+
+// RoleUseCaseParam injects the role + role-permission + user-role repos.
+// User roles are needed so Delete can refuse when assignments still exist.
+type RoleUseCaseParam struct {
+ Roles domrepo.RoleRepository
+ RolePermissions domrepo.RolePermissionRepository
+ UserRoles domrepo.UserRoleRepository
+}
+
+type roleUseCase struct {
+ roles domrepo.RoleRepository
+ rolePerms domrepo.RolePermissionRepository
+ userRoles domrepo.UserRoleRepository
+}
+
+// NewRoleUseCase returns the tenant-scoped role manager.
+func NewRoleUseCase(param RoleUseCaseParam) dom.RoleUseCase {
+ return &roleUseCase{
+ roles: param.Roles,
+ rolePerms: param.RolePermissions,
+ userRoles: param.UserRoles,
+ }
+}
+
+func (uc *roleUseCase) Create(ctx context.Context, param *dom.CreateRoleParam) (*entity.Role, error) {
+ if param == nil || param.TenantID == "" {
+ return nil, errb.InputMissingRequired("tenant_id")
+ }
+ if err := validateRoleKey(param.Key); err != nil {
+ return nil, err
+ }
+ displayName := strings.TrimSpace(param.DisplayName)
+ if displayName == "" {
+ displayName = param.Key
+ }
+ if len(displayName) > domain.RoleDisplayNameMax {
+ return nil, errb.InputInvalidRange("display_name too long")
+ }
+ status := param.Status
+ if status == "" {
+ status = enum.StatusOpen
+ }
+ if !status.IsValid() {
+ return nil, errb.InputInvalidFormat("invalid status")
+ }
+ role := &entity.Role{
+ TenantID: param.TenantID,
+ Key: param.Key,
+ DisplayName: displayName,
+ CreatorUID: param.CreatorUID,
+ Status: status,
+ IsSystem: false,
+ }
+ if err := uc.roles.Insert(ctx, role); err != nil {
+ return nil, wrapRepoErr(err, "create role")
+ }
+ return role, nil
+}
+
+func (uc *roleUseCase) Get(ctx context.Context, tenantID, id string) (*entity.Role, error) {
+ role, err := uc.roles.GetByID(ctx, tenantID, id)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return role, nil
+}
+
+func (uc *roleUseCase) GetByKey(ctx context.Context, tenantID, key string) (*entity.Role, error) {
+ role, err := uc.roles.GetByKey(ctx, tenantID, key)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return role, nil
+}
+
+func (uc *roleUseCase) List(ctx context.Context, tenantID string) ([]*entity.Role, error) {
+ roles, err := uc.roles.ListByTenant(ctx, tenantID)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ return roles, nil
+}
+
+func (uc *roleUseCase) Update(
+ ctx context.Context,
+ tenantID, id string,
+ param *dom.UpdateRoleParam,
+) (*entity.Role, error) {
+ if param == nil {
+ return uc.Get(ctx, tenantID, id)
+ }
+ existing, err := uc.roles.GetByID(ctx, tenantID, id)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if existing.IsSystem && param.Status != nil {
+ return nil, wrapRepoErr(domain.ErrRoleSystemImmutable)
+ }
+ update := &domrepo.RoleUpdate{}
+ if param.DisplayName != nil {
+ display := strings.TrimSpace(*param.DisplayName)
+ if display == "" {
+ return nil, errb.InputMissingRequired("display_name")
+ }
+ if len(display) > domain.RoleDisplayNameMax {
+ return nil, errb.InputInvalidRange("display_name too long")
+ }
+ update.DisplayName = &display
+ }
+ if param.Status != nil {
+ if !param.Status.IsValid() {
+ return nil, errb.InputInvalidFormat("invalid status")
+ }
+ s := param.Status.String()
+ update.Status = &s
+ }
+ role, err := uc.roles.Update(ctx, tenantID, id, update)
+ if err != nil {
+ return nil, wrapRepoErr(err, "update role")
+ }
+ return role, nil
+}
+
+func (uc *roleUseCase) Delete(ctx context.Context, tenantID, id string) error {
+ existing, err := uc.roles.GetByID(ctx, tenantID, id)
+ if err != nil {
+ return wrapRepoErr(err)
+ }
+ if existing.IsSystem {
+ return wrapRepoErr(domain.ErrRoleSystemImmutable)
+ }
+ assignments, err := uc.userRoles.ListByRole(ctx, tenantID, id)
+ if err != nil {
+ return wrapRepoErr(err, "check user roles")
+ }
+ if len(assignments) > 0 {
+ return errb.ResPreconditionFailed("role still has assignments")
+ }
+ if err := uc.rolePerms.DeleteByRole(ctx, tenantID, id); err != nil {
+ return wrapRepoErr(err, "delete role permissions")
+ }
+ if err := uc.roles.Delete(ctx, tenantID, id); err != nil {
+ return wrapRepoErr(err, "delete role")
+ }
+ return nil
+}
+
+func validateRoleKey(key string) error {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return errb.InputMissingRequired("key")
+ }
+ if len(key) < domain.RoleKeyMinLength || len(key) > domain.RoleKeyMaxLength {
+ return errb.InputInvalidRange("key length")
+ }
+ if !roleKeyPattern.MatchString(key) {
+ return wrapRepoErr(domain.ErrRoleKeyInvalid)
+ }
+ for _, prefix := range domain.ReservedRoleKeyPrefixes {
+ if strings.HasPrefix(key, prefix) {
+ return wrapRepoErr(domain.ErrRoleKeyReserved)
+ }
+ }
+ return nil
+}
+
+var _ dom.RoleUseCase = (*roleUseCase)(nil)
diff --git a/internal/model/permission/usecase/user_role_usecase.go b/internal/model/permission/usecase/user_role_usecase.go
new file mode 100644
index 0000000..b4a162d
--- /dev/null
+++ b/internal/model/permission/usecase/user_role_usecase.go
@@ -0,0 +1,147 @@
+package usecase
+
+import (
+ "context"
+
+ "gateway/internal/model/permission/domain"
+ "gateway/internal/model/permission/domain/entity"
+ "gateway/internal/model/permission/domain/enum"
+ domrepo "gateway/internal/model/permission/domain/repository"
+ dom "gateway/internal/model/permission/domain/usecase"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+// UserRoleUseCaseParam injects role + user-role repositories.
+type UserRoleUseCaseParam struct {
+ Roles domrepo.RoleRepository
+ UserRoles domrepo.UserRoleRepository
+ Reloader PolicyReloader
+}
+
+type userRoleUseCase struct {
+ roles domrepo.RoleRepository
+ userRoles domrepo.UserRoleRepository
+ reload PolicyReloader
+}
+
+// NewUserRoleUseCase returns the assignment manager used by tenant
+// admins and SyncFromX flows.
+func NewUserRoleUseCase(param UserRoleUseCaseParam) dom.UserRoleUseCase {
+ return &userRoleUseCase{
+ roles: param.Roles,
+ userRoles: param.UserRoles,
+ reload: param.Reloader,
+ }
+}
+
+func (uc *userRoleUseCase) Assign(ctx context.Context, param *dom.AssignParam) (*entity.UserRole, error) {
+ if param == nil || param.TenantID == "" || param.UID == "" || param.RoleID == "" {
+ return nil, errb.InputMissingRequired("tenant_id|uid|role_id")
+ }
+ role, err := uc.roles.GetByID(ctx, param.TenantID, param.RoleID)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ source := param.Source
+ if source == "" {
+ source = enum.RoleSourceManual
+ }
+ if !source.IsValid() {
+ return nil, errb.InputInvalidFormat("invalid source")
+ }
+ ur := &entity.UserRole{
+ TenantID: param.TenantID,
+ UID: param.UID,
+ RoleID: role.ID.Hex(),
+ Source: source,
+ }
+ if err := uc.userRoles.Insert(ctx, ur); err != nil {
+ return nil, wrapRepoErr(err, "assign role")
+ }
+ uc.broadcast(ctx, param.TenantID)
+ return ur, nil
+}
+
+func (uc *userRoleUseCase) Revoke(ctx context.Context, tenantID, uid, roleID string) error {
+ if err := uc.userRoles.Delete(ctx, tenantID, uid, roleID); err != nil {
+ return wrapRepoErr(err, "revoke role")
+ }
+ uc.broadcast(ctx, tenantID)
+ return nil
+}
+
+func (uc *userRoleUseCase) List(ctx context.Context, tenantID, uid string) ([]*dom.UserRoleSummary, error) {
+ rows, err := uc.userRoles.ListByUser(ctx, tenantID, uid)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ if len(rows) == 0 {
+ return nil, nil
+ }
+ ids := make([]string, 0, len(rows))
+ for _, ur := range rows {
+ ids = append(ids, ur.RoleID)
+ }
+ roles, err := uc.roles.ListByTenantAndIDs(ctx, tenantID, ids)
+ if err != nil {
+ return nil, wrapRepoErr(err)
+ }
+ roleByID := make(map[string]*entity.Role, len(roles))
+ for _, role := range roles {
+ roleByID[role.ID.Hex()] = role
+ }
+ out := make([]*dom.UserRoleSummary, 0, len(rows))
+ for _, ur := range rows {
+ summary := &dom.UserRoleSummary{UserRole: ur}
+ if role, ok := roleByID[ur.RoleID]; ok {
+ summary.RoleKey = role.Key
+ summary.RoleDisplayName = role.DisplayName
+ }
+ out = append(out, summary)
+ }
+ return out, nil
+}
+
+func (uc *userRoleUseCase) ReplaceForSource(
+ ctx context.Context,
+ tenantID, uid string,
+ source enum.RoleSource,
+ roleKeys []string,
+) error {
+ if !source.IsValid() {
+ return errb.InputInvalidFormat("invalid source")
+ }
+ if source == enum.RoleSourceManual {
+ // Manual assignments are managed via Assign/Revoke; protect from
+ // accidental wipe by SyncFromX flows (defence in depth).
+ return errb.ResInvalidState("manual source cannot be batch-replaced")
+ }
+ roleIDs := make([]string, 0, len(roleKeys))
+ for _, key := range roleKeys {
+ role, err := uc.roles.GetByKey(ctx, tenantID, key)
+ if err != nil {
+ // Skip unknown keys silently — keeps SyncFromX resilient when
+ // the IdP exposes groups the tenant has not mapped yet.
+ continue
+ }
+ roleIDs = append(roleIDs, role.ID.Hex())
+ }
+ if err := uc.userRoles.ReplaceForSource(ctx, tenantID, uid, source, roleIDs); err != nil {
+ return wrapRepoErr(err, "replace user roles")
+ }
+ uc.broadcast(ctx, tenantID)
+ return nil
+}
+
+func (uc *userRoleUseCase) broadcast(ctx context.Context, tenantID string) {
+ if uc.reload == nil {
+ return
+ }
+ if err := uc.reload(ctx, tenantID); err != nil {
+ logx.WithContext(ctx).Errorf("permission user-role: broadcast reload tenant=%s: %v", tenantID, err)
+ }
+}
+
+var _ dom.UserRoleUseCase = (*userRoleUseCase)(nil)
+var _ = domain.ReservedRoleKeyPrefixes // ensure domain package referenced for go build
diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go
index 940dcea..11f1116 100644
--- a/internal/svc/service_context.go
+++ b/internal/svc/service_context.go
@@ -20,6 +20,9 @@ import (
memberusecase "gateway/internal/model/member/usecase"
domnotif "gateway/internal/model/notification/domain/usecase"
notifusecase "gateway/internal/model/notification/usecase"
+ dompermrepo "gateway/internal/model/permission/domain/repository"
+ domperm "gateway/internal/model/permission/domain/usecase"
+ permusecase "gateway/internal/model/permission/usecase"
"gateway/internal/worker/notification_retry"
)
@@ -45,6 +48,16 @@ type ServiceContext struct {
MemberTenant dommember.TenantUseCase
MemberVerifyRate dommember.VerifyRateUseCase
MemberRepo domrepo.MemberRepository
+
+ PermissionCatalog domperm.PermissionUseCase
+ PermissionRole domperm.RoleUseCase
+ PermissionRolePermission domperm.RolePermissionUseCase
+ PermissionUserRole domperm.UserRoleUseCase
+ PermissionRoleMapping domperm.RoleMappingUseCase
+ PermissionAuthQuery domperm.AuthorizationQueryUseCase
+ PermissionRBAC domperm.RBACUseCase
+ PermissionRoleRepo dompermrepo.RoleRepository
+ permissionModule *permusecase.Module
}
func NewServiceContext(c config.Config) *ServiceContext {
@@ -126,6 +139,25 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberRepo = memberMod.Members
}
+ if c.Mongo.Host != "" {
+ permMod, err := permusecase.NewModuleFromParam(permusecase.FactoryParam{
+ MongoConf: &c.Mongo,
+ Redis: rds,
+ Config: c.Permission,
+ })
+ if err != nil {
+ panic(err)
+ }
+ sc.PermissionCatalog = permMod.Permission
+ sc.PermissionRole = permMod.Role
+ sc.PermissionRolePermission = permMod.RolePermission
+ sc.PermissionUserRole = permMod.UserRole
+ sc.PermissionRoleMapping = permMod.RoleMapping
+ sc.PermissionAuthQuery = permMod.AuthorizationQuery
+ sc.PermissionRBAC = permMod.RBAC
+ sc.PermissionRoleRepo = permMod.Roles
+ sc.permissionModule = permMod
+ }
return sc
}
@@ -133,10 +165,18 @@ func (sc *ServiceContext) StartWorkers(ctx context.Context) {
if sc.NotificationRetry != nil {
sc.NotificationRetry.Start(ctx)
}
+ if sc.permissionModule != nil {
+ if err := sc.permissionModule.StartBackground(ctx); err != nil {
+ panic(err)
+ }
+ }
}
func (sc *ServiceContext) StopWorkers() {
if sc.NotificationRetry != nil {
sc.NotificationRetry.Stop()
}
+ if sc.permissionModule != nil {
+ sc.permissionModule.StopBackground()
+ }
}
diff --git a/internal/types/types.go b/internal/types/types.go
index ef13212..905abb4 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -9,6 +9,17 @@ type APIErrorStatus struct {
Error ErrorDetail `json:"error"`
}
+type AssignUserRoleByUIDReq struct {
+ UID string `path:"uid"`
+ RoleID string `json:"role_id" validate:"required"`
+ Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
+}
+
+type AssignUserRoleReq struct {
+ RoleID string `json:"role_id" validate:"required"`
+ Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
+}
+
type AuthTokenData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
@@ -17,6 +28,32 @@ type AuthTokenData struct {
TokenType string `json:"token_type"`
}
+type AuthTokenOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data AuthTokenData `json:"data"`
+}
+
+type CreateRoleReq struct {
+ Key string `json:"key" validate:"required,min=2,max=64"`
+ DisplayName string `json:"display_name,optional"`
+ Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
+}
+
+type DeleteRoleByIDReq struct {
+ ID string `path:"id"`
+}
+
+type DeleteRoleMappingReq struct {
+ ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
+ ExternalKey string `json:"external_key" validate:"required"`
+}
+
+type EmptyOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+}
+
type ErrorDetail struct {
BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"`
@@ -24,6 +61,14 @@ type ErrorDetail struct {
Detail uint32 `json:"detail,omitempty"`
}
+type GetRolePermissionsByIDReq struct {
+ ID string `path:"id"`
+}
+
+type ListUserRolesReq struct {
+ UID string `path:"uid"`
+}
+
type LoginReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
Email string `json:"email" validate:"required,email"`
@@ -41,6 +86,12 @@ type LoginSocialStartData struct {
ExpiresIn int `json:"expires_in"`
}
+type LoginSocialStartOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data LoginSocialStartData `json:"data"`
+}
+
type LoginSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
Provider string `json:"provider" validate:"required,oneof=google"`
@@ -51,6 +102,30 @@ type LogoutData struct {
OK bool `json:"ok"`
}
+type LogoutOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data LogoutData `json:"data"`
+}
+
+type MePermissionsData struct {
+ UID string `json:"uid"`
+ TenantID string `json:"tenant_id"`
+ Roles []string `json:"roles"`
+ Permissions map[string]string `json:"permissions"`
+ Tree []PermissionNode `json:"tree,omitempty"`
+}
+
+type MePermissionsOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data MePermissionsData `json:"data"`
+}
+
+type MePermissionsQuery struct {
+ IncludeTree bool `form:"include_tree,optional"`
+}
+
type MemberMeData struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
@@ -71,6 +146,40 @@ type MemberMeData struct {
UpdateAt int64 `json:"update_at"`
}
+type MemberMeOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data MemberMeData `json:"data"`
+}
+
+type PermissionCatalogData struct {
+ Tree []PermissionNode `json:"tree,omitempty"`
+ List []PermissionNode `json:"list,omitempty"`
+}
+
+type PermissionCatalogOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PermissionCatalogData `json:"data"`
+}
+
+type PermissionCatalogQuery struct {
+ Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
+ Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
+ Tree bool `form:"tree,optional"`
+}
+
+type PermissionNode struct {
+ ID string `json:"id"`
+ Parent string `json:"parent,omitempty"`
+ Name string `json:"name"`
+ HTTPMethods string `json:"http_methods,omitempty"`
+ HTTPPath string `json:"http_path,omitempty"`
+ Status string `json:"status"`
+ Type string `json:"type"`
+ Children []PermissionNode `json:"children,omitempty"`
+}
+
type PingData struct {
Pong string `json:"pong"`
}
@@ -81,6 +190,21 @@ type PingOKStatus struct {
Data PingData `json:"data"`
}
+type PolicyReloadData struct {
+ Tenant string `json:"tenant"`
+ TS int64 `json:"ts"`
+}
+
+type PolicyReloadOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PolicyReloadData `json:"data"`
+}
+
+type PolicyReloadReq struct {
+ TenantID string `json:"tenant_id,optional"`
+}
+
type RegisterConfirmReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
ChallengeID string `json:"challenge_id" validate:"required"`
@@ -93,6 +217,12 @@ type RegisterData struct {
UID string `json:"uid"`
}
+type RegisterOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RegisterData `json:"data"`
+}
+
type RegisterReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
InviteCode string `json:"invite_code" validate:"required"`
@@ -120,6 +250,12 @@ type RegisterSocialStartData struct {
ExpiresIn int `json:"expires_in"`
}
+type RegisterSocialStartOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RegisterSocialStartData `json:"data"`
+}
+
type RegisterSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
InviteCode string `json:"invite_code" validate:"required"`
@@ -130,14 +266,114 @@ type RegisterSocialStartReq struct {
MarketingOptIn bool `json:"marketing_opt_in,optional"`
}
+type ReplaceRolePermissionsByIDReq struct {
+ ID string `path:"id"`
+ PermissionIDs []string `json:"permission_ids"`
+}
+
+type ReplaceRolePermissionsReq struct {
+ PermissionIDs []string `json:"permission_ids"`
+}
+
+type RevokeUserRoleByIDReq struct {
+ UID string `path:"uid"`
+ RoleID string `path:"role_id"`
+}
+
+type RoleData struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ Key string `json:"key"`
+ DisplayName string `json:"display_name"`
+ CreatorUID string `json:"creator_uid,omitempty"`
+ Status string `json:"status"`
+ IsSystem bool `json:"is_system"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type RoleListData struct {
+ Roles []RoleData `json:"roles"`
+}
+
+type RoleListOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleListData `json:"data"`
+}
+
+type RoleMappingData struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ ExternalSource string `json:"external_source"`
+ ExternalKey string `json:"external_key"`
+ InternalRoleID string `json:"internal_role_id"`
+ InternalRoleKey string `json:"internal_role_key"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type RoleMappingListData struct {
+ Mappings []RoleMappingData `json:"mappings"`
+ Total int64 `json:"total"`
+ Offset int64 `json:"offset"`
+ Limit int64 `json:"limit"`
+}
+
+type RoleMappingListOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleMappingListData `json:"data"`
+}
+
+type RoleMappingListQuery struct {
+ Source string `form:"source,optional" validate:"omitempty,oneof=zitadel ldap scim"`
+ Offset int64 `form:"offset,optional"`
+ Limit int64 `form:"limit,optional"`
+}
+
+type RoleMappingOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleMappingData `json:"data"`
+}
+
+type RoleOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RoleData `json:"data"`
+}
+
+type RolePermissionsListData struct {
+ Permissions []PermissionNode `json:"permissions"`
+}
+
+type RolePermissionsListOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data RolePermissionsListData `json:"data"`
+}
+
type TOTPBackupCodesData struct {
BackupCodes []string `json:"backup_codes"`
}
+type TOTPBackupCodesOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPBackupCodesData `json:"data"`
+}
+
type TOTPEnrollConfirmData struct {
BackupCodes []string `json:"backup_codes"`
}
+type TOTPEnrollConfirmOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPEnrollConfirmData `json:"data"`
+}
+
type TOTPEnrollConfirmReq struct {
Code string `json:"code"`
}
@@ -151,6 +387,12 @@ type TOTPEnrollStartData struct {
ExpiresIn int `json:"expires_in"`
}
+type TOTPEnrollStartOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPEnrollStartData `json:"data"`
+}
+
type TOTPStatusData struct {
Enrolled bool `json:"enrolled"`
EnrolledAt int64 `json:"enrolled_at,omitempty"`
@@ -159,6 +401,12 @@ type TOTPStatusData struct {
PeriodSeconds int `json:"period_seconds,omitempty"`
}
+type TOTPStatusOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data TOTPStatusData `json:"data"`
+}
+
type TOTPVerifyReq struct {
Code string `json:"code"`
}
@@ -172,6 +420,10 @@ type TokenRefreshReq struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
+type UIDPath struct {
+ UID string `path:"uid"`
+}
+
type UpdateMemberMeReq struct {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`
@@ -180,6 +432,56 @@ type UpdateMemberMeReq struct {
Phone string `json:"phone,optional"`
}
+type UpdateRoleByIDReq struct {
+ ID string `path:"id"`
+ DisplayName string `json:"display_name,optional"`
+ Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
+}
+
+type UpdateRoleReq struct {
+ DisplayName string `json:"display_name,optional"`
+ Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
+}
+
+type UpsertRoleMappingReq struct {
+ ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
+ ExternalKey string `json:"external_key" validate:"required"`
+ InternalRoleKey string `json:"internal_role_key" validate:"required"`
+}
+
+type UserRoleData struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ UID string `json:"uid"`
+ RoleID string `json:"role_id"`
+ RoleKey string `json:"role_key"`
+ RoleDisplayName string `json:"role_display_name"`
+ Source string `json:"source"`
+ CreateAt int64 `json:"create_at"`
+ UpdateAt int64 `json:"update_at"`
+}
+
+type UserRoleIDPath struct {
+ UID string `path:"uid"`
+ RoleID string `path:"role_id"`
+}
+
+type UserRoleListData struct {
+ UserRoles []UserRoleData `json:"user_roles"`
+}
+
+type UserRoleListOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data UserRoleListData `json:"data"`
+}
+
+type UserRoleOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data UserRoleData `json:"data"`
+}
+
type VerificationConfirmReq struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
@@ -190,6 +492,12 @@ type VerificationStartData struct {
ExpiresIn int `json:"expires_in"`
}
+type VerificationStartOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data VerificationStartData `json:"data"`
+}
+
type VerificationStartReq struct {
Target string `json:"target"`
}