feat(permission): add RBAC module with Casbin enforcement and policy reload

- Multi-tenant RBAC: permission catalog, roles, role-permission mapping,
  user-role assignment, and external IdP role mapping (zitadel/ldap/scim).
- Casbin enforcer with Redis-backed adapter and Pub/Sub reload for
  multi-instance policy sync; HTTP middleware enforces (tenant, role,
  path, method) with platform admin bypass.
- /api/v1/permissions routes: catalog, me, policy/reload, roles CRUD,
  role permissions, user roles, role mappings.
- New error scope (31) for Permission and biz code descriptions.
- Wire Permission module into ServiceContext, config, mongo-index, and
  add cmd/permission-seed CLI plus etc/rbac.conf model.
- Redis client gains lazy PubSubClient helper (go-zero wrapper lacks Subscribe).
- Rewrite internal/model/member/README to cover Tenant/Member/Identity.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-05-21 16:47:35 +08:00
parent 713a81f70b
commit fa50c64ee4
95 changed files with 7871 additions and 318 deletions

View File

@ -12,6 +12,7 @@ import (
authrepo "gateway/internal/model/auth/repository" authrepo "gateway/internal/model/auth/repository"
memberrepo "gateway/internal/model/member/repository" memberrepo "gateway/internal/model/member/repository"
notifrepo "gateway/internal/model/notification/repository" notifrepo "gateway/internal/model/notification/repository"
permrepo "gateway/internal/model/permission/repository"
"github.com/zeromicro/go-zero/core/conf" "github.com/zeromicro/go-zero/core/conf"
) )
@ -52,7 +53,10 @@ func run() error {
if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: auth: %w", err) 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 return nil
} }

View File

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

View File

@ -91,6 +91,20 @@ Auth:
RefreshSecret: "dev-refresh-secret-32-bytes-min!" RefreshSecret: "dev-refresh-secret-32-bytes-min!"
RegistrationSessionTTLSeconds: 600 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+) # ZITADEL identity backend (auth register/login — PR 1+)
# ServiceUserToken: export ZITADEL_SERVICE_TOKEN=... # ServiceUserToken: export ZITADEL_SERVICE_TOKEN=...
# OAuthClientSecret: export ZITADEL_OAUTH_CLIENT_SECRET=... # OAuthClientSecret: export ZITADEL_OAUTH_CLIENT_SECRET=...

25
etc/rbac.conf Normal file
View File

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

View File

@ -11,7 +11,7 @@ info (
consumes: "application/json" consumes: "application/json"
produces: "application/json" produces: "application/json"
useDefinitions: true useDefinitions: true
bizCodeEnumDescription: "102000-成功<br>10101000-參數格式錯誤(Facade)<br>10104000-缺少必填欄位(Facade)<br>28101000-參數格式錯誤(Auth)<br>28104000-缺少必填欄位(Auth)<br>28201000-資料庫錯誤(Auth)<br>28301000-資源不存在(Auth)<br>28303000-資源已存在(Auth)<br>28309000-資源狀態無效(Auth)<br>28310000-配額不足(Auth)<br>28313000-資源鎖定(Auth)<br>28501000-未授權(Auth)<br>28505000-禁止存取(Auth)<br>28601000-系統內部錯誤(Auth)<br>28604000-請求過於頻繁(Auth)<br>28605000-功能未配置(Auth)<br>28802000-第三方服務錯誤(Auth)<br>29104000-缺少必填欄位(Member)<br>29201000-資料庫錯誤(Member)<br>29301000-資源不存在(Member)<br>29303000-資源已存在(Member)<br>29309000-資源狀態無效(Member)<br>29310000-配額不足(Member)<br>29501000-未授權(Member)<br>29505000-禁止存取(Member)<br>29601000-系統內部錯誤(Member)<br>29604000-請求過於頻繁(Member)<br>29605000-功能未配置(Member)" bizCodeEnumDescription: "102000-成功<br>10101000-參數格式錯誤(Facade)<br>10104000-缺少必填欄位(Facade)<br>28101000-參數格式錯誤(Auth)<br>28104000-缺少必填欄位(Auth)<br>28201000-資料庫錯誤(Auth)<br>28301000-資源不存在(Auth)<br>28303000-資源已存在(Auth)<br>28309000-資源狀態無效(Auth)<br>28310000-配額不足(Auth)<br>28313000-資源鎖定(Auth)<br>28501000-未授權(Auth)<br>28505000-禁止存取(Auth)<br>28601000-系統內部錯誤(Auth)<br>28604000-請求過於頻繁(Auth)<br>28605000-功能未配置(Auth)<br>28802000-第三方服務錯誤(Auth)<br>29104000-缺少必填欄位(Member)<br>29201000-資料庫錯誤(Member)<br>29301000-資源不存在(Member)<br>29303000-資源已存在(Member)<br>29309000-資源狀態無效(Member)<br>29310000-配額不足(Member)<br>29501000-未授權(Member)<br>29505000-禁止存取(Member)<br>29601000-系統內部錯誤(Member)<br>29604000-請求過於頻繁(Member)<br>29605000-功能未配置(Member)<br>31101000-參數格式錯誤(Permission)<br>31201000-資料庫錯誤(Permission)<br>31301000-資源不存在(Permission)<br>31303000-資源已存在(Permission)<br>31309000-資源狀態無效(Permission)<br>31312000-前置條件失敗(Permission)<br>31501000-未授權(Permission)<br>31601000-系統內部錯誤(Permission)<br>31605000-功能未配置(Permission)"
) )
import ( import (
@ -19,5 +19,6 @@ import (
"common.api" "common.api"
"member.api" "member.api"
"normal.api" "normal.api"
"permission.api"
) )

526
generate/api/permission.api Normal file
View File

@ -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 / statusis_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=manualsource 來源由 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)
}

3
go.mod
View File

@ -25,6 +25,9 @@ require (
github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 // indirect github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 // indirect
github.com/aws/smithy-go v1.22.2 // indirect github.com/aws/smithy-go v1.22.2 // indirect
github.com/beorn7/perks v1.0.1 // 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/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect

9
go.sum
View File

@ -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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 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 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 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 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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/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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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-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.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/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= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -12,6 +12,7 @@ import (
authconfig "gateway/internal/model/auth/config" authconfig "gateway/internal/model/auth/config"
memberconfig "gateway/internal/model/member/config" memberconfig "gateway/internal/model/member/config"
notifconfig "gateway/internal/model/notification/config" notifconfig "gateway/internal/model/notification/config"
permconfig "gateway/internal/model/permission/config"
) )
type Config struct { type Config struct {
@ -22,4 +23,5 @@ type Config struct {
Zitadel zitadel.Conf `json:",optional"` Zitadel zitadel.Conf `json:",optional"`
Notification notifconfig.Config `json:",optional"` Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"` Member memberconfig.Config `json:",optional"`
Permission permconfig.Config `json:",optional"`
} }

View File

@ -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=manualsource 來源由 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)
}
}

View File

@ -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"))
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 / statusis_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)
}
}

View File

@ -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)
}
}

View File

@ -10,6 +10,7 @@ import (
auth "gateway/internal/handler/auth" auth "gateway/internal/handler/auth"
member "gateway/internal/handler/member" member "gateway/internal/handler/member"
normal "gateway/internal/handler/normal" normal "gateway/internal/handler/normal"
permission "gateway/internal/handler/permission"
"gateway/internal/svc" "gateway/internal/svc"
"github.com/zeromicro/go-zero/rest" "github.com/zeromicro/go-zero/rest"
@ -36,6 +37,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/login/social/start", Path: "/login/social/start",
Handler: auth.LoginSocialStartHandler(serverCtx), Handler: auth.LoginSocialStartHandler(serverCtx),
}, },
{
// 登出(撤銷 access JWT 及配對 refresh JWT
Method: http.MethodPost,
Path: "/logout",
Handler: auth.LogoutHandler(serverCtx),
},
{ {
// Email 註冊(建立 ZITADEL + member寄 registration OTP // Email 註冊(建立 ZITADEL + member寄 registration OTP
Method: http.MethodPost, Method: http.MethodPost,
@ -66,12 +73,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/register/social/start", Path: "/register/social/start",
Handler: auth.RegisterSocialStartHandler(serverCtx), Handler: auth.RegisterSocialStartHandler(serverCtx),
}, },
{
// 登出(撤銷 access JWT 及配對 refresh JWT
Method: http.MethodPost,
Path: "/logout",
Handler: auth.LogoutHandler(serverCtx),
},
{ {
// ZITADEL id_token 換 CloudEP JWT企業 SSO // ZITADEL id_token 換 CloudEP JWT企業 SSO
Method: http.MethodPost, Method: http.MethodPost,
@ -178,4 +179,100 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"), rest.WithPrefix("/api/v1"),
rest.WithTimeout(3000*time.Millisecond), 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 / statusis_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=manualsource 來源由 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"),
)
} }

View File

@ -140,4 +140,5 @@ const (
Auth Scope = 28 Auth Scope = 28
Member Scope = 29 Member Scope = 29
Notification Scope = 30 Notification Scope = 30
Permission Scope = 31
) )

View File

@ -11,6 +11,7 @@ import (
// Client wraps go-zero Redis so all modules share the same connection pool. // Client wraps go-zero Redis so all modules share the same connection pool.
type Client struct { type Client struct {
r *redis.Redis r *redis.Redis
pubSubFields
} }
// NewClient returns a shared Redis client, or (nil, nil) when Host is empty. // NewClient returns a shared Redis client, or (nil, nil) when Host is empty.

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

@ -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,
)
}

View File

@ -0,0 +1,8 @@
package permission
import (
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
)
var errb = errs.For(code.Permission)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

@ -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)
}

View File

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

View File

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

View File

@ -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)
}
}
}

View File

@ -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 | 用途 | 典型場景 | > **架構原則**(`docs/model.md` §6.1):
|---------|------|----------| > usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。
| **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 | > 本 module 所有 usecase 都是 **atomic primitives**
| **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA |
> **架構原則**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/ internal/model/member/
├── config/ # OTP / TOTP 設定 ├── config/ # OTP / TOTP / Registration 設定
├── domain/ # 介面、enum、errors、redis key ├── domain/ # 介面、enum、entity、errors、redis key helper
│ ├── enum/ │ ├── const.go # BSON 欄位、UID 常數
│ ├── repository/ │ ├── entity/ # Member、Tenant、Identity Mongo doc
│ └── usecase/ │ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind
├── repository/ # Redis / memory 實作 │ ├── errors.go # ErrNotFound、ErrDuplicateMember 等
├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library │ ├── redis.go # GetOTPChallengeRedisKey 等 helper
├── usecase/ # OTPUseCase、TOTPUseCase 實作 │ ├── repository/ # 7 個 repository 介面
└── README.md │ └── 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`。
--- ---
## OTPOne-Time Password ## Module 結構與依賴
### 原理 ```mermaid
flowchart TB
Logic["logic 層<br/>(handler 編排)"]
1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 RedisTTL 預設 300 秒。 subgraph M["member.Module (atomic usecases)"]
2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次logic 層負責呼叫 `notification.Notifier.Send` 投遞。 direction LR
3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt成功後 **刪除 challenge**(一次性)。 OTP["OTP"]
4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5即鎖定該 challenge。 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<br/>(platform 註冊)
[*] --> active: Provisioning.Ensure*<br/>(OIDC/LDAP/SCIM 首登)
unverified --> active: Activate<br/>(OTP 驗證通過)
unverified --> deleted: AbortPending<br/>(註冊逾時)
active --> suspended: Suspend(reason)
suspended --> active: Reactivate
active --> deleted: SoftDelete
suspended --> deleted: SoftDelete
deleted --> [*]
```
`transition()` 強制 `from → to`,不符回 `ErrInvalidStatus`
---
## 核心流程時序圖
### 1. 模組裝配 (NewModuleFromParam)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant Logic autonumber
participant OTP as OTPUseCase participant SVC as svc.NewServiceContext
participant Mod as member.NewModuleFromParam
participant Repo as repository
participant Redis participant Redis
participant Notifier participant Mongo
Logic->>OTP: Generate(tenant, uid, purpose, target) SVC->>Mod: ModuleParam{Redis, MongoConf, Config}
OTP->>Redis: Save challenge (bcrypt hash) Mod->>Repo: NewRedisOTPChallengeStore(redis)
OTP-->>Logic: challenge_id, plainCode Mod->>Repo: NewRedisVerifyRateStore(redis)
Logic->>Notifier: Send(code, expires_in) alt MongoConf.Host != ""
Note over Logic: 使用者收到信/簡訊 Mod->>Repo: NewMemberRepository / NewTenantRepository / NewIdentityRepository
Logic->>OTP: Verify(challenge_id, code, uid, purpose) Mod->>Repo: NewMongoTOTPProfileRepository
OTP->>Redis: Get + bcrypt compare Repo->>Mongo: ping (lazy)
OTP->>Redis: Delete challenge end
OTP-->>Logic: target (email/phone) Mod->>Repo: NewRedisUIDGenerator(redis)
Logic->>Logic: Profile.SetBusinessEmailVerified(...) 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用途標籤 ### 2. Tenant 建立
```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 limitlogic 層使用)
`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)
```
---
## TOTPTime-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**同一時間窗口內不可重放**。
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant User autonumber
participant CLI as cmd/member-seed
participant TenantUC as TenantUseCase
participant Repo as TenantRepository
participant Mongo
CLI->>TenantUC: Create(req{TenantID, Slug, Name, UIDPrefix})
TenantUC->>TenantUC: normalizeUIDPrefix + 長度檢查 (2-4)
TenantUC->>Repo: GetByUIDPrefix(prefix)
Repo->>Mongo: findOne
alt prefix 已存在
TenantUC-->>CLI: ErrAlreadyExist("uid_prefix already exists")
else 不存在
TenantUC->>Repo: Insert(Tenant{Status: active})
Repo->>Mongo: insertOne
TenantUC-->>CLI: TenantDTO
end
```
### 3. Platform 註冊 (auth + member.Lifecycle)
> 屬於 `internal/logic/auth/register_logic.go` 的編排;Member module 只負責 atomic 動作。
```mermaid
sequenceDiagram
autonumber
participant Client
participant RegLogic as logic/auth.RegisterLogic
participant TenantUC as TenantUseCase
participant Zitadel as library/zitadel
participant Lifecycle as LifecycleUseCase
participant OTP as OTPUseCase
participant Notifier
participant Confirm as logic/auth.RegisterConfirmLogic
Client->>RegLogic: POST /auth/register {tenant_slug, email, password}
RegLogic->>TenantUC: ResolveBySlug(slug)
TenantUC-->>RegLogic: TenantDTO
RegLogic->>Zitadel: CreateHumanUser(...)
Zitadel-->>RegLogic: zitadel_user_id
RegLogic->>Lifecycle: CreateUnverified(req{tenant, email, hash, zitadel_user_id})
Lifecycle->>Lifecycle: 取 tenant.UIDPrefix → UIDGenerator.Next
Lifecycle->>Lifecycle: members.Insert(status=unverified)
Lifecycle-->>RegLogic: MemberDTO(uid)
RegLogic->>OTP: Generate(purpose=Register, uid, target=email)
OTP-->>RegLogic: challenge_id, plainCode
RegLogic->>Notifier: Send(VerifyEmail, code)
alt Notifier 失敗
RegLogic->>Lifecycle: AbortPending(uid)
RegLogic-->>Client: 5xx
else 成功
RegLogic-->>Client: {challenge_id, expires_in}
end
Note over Client,Confirm: 使用者收到信
Client->>Confirm: POST /auth/register/confirm {challenge_id, code}
Confirm->>OTP: MatchChallenge(challenge_id, tenant, purpose=Register, RequireUID)
OTP-->>Confirm: OTPChallengeInfo{uid}
Confirm->>OTP: Verify(challenge_id, code, uid, purpose)
OTP-->>Confirm: target(email)
Confirm->>Lifecycle: Activate(tenant, uid) // unverified → active
Confirm-->>Client: JWT (auth 簽發)
```
### 4. Provisioning — OIDC / LDAP / SCIM
外部身份首次登入時透過 `EnsureFromOIDC` upsert,**冪等**(既存即回傳)。
```mermaid
sequenceDiagram
autonumber
participant Logic as logic/auth.LoginSocialCallback
participant Prov as ProvisioningUseCase
participant MR as MemberRepository
participant IR as IdentityRepository
participant TR as TenantRepository
participant UID as UIDGenerator
participant Redis
participant Mongo
Logic->>Prov: EnsureFromOIDC(tenant, zitadel_sub, email, ...)
Prov->>MR: GetByZitadelUserID(tenant, sub)
MR->>Mongo: find
alt 已存在
MR-->>Prov: Member
Prov-->>Logic: MemberDTO (origin=oidc, status=active)
else ErrNotFound
Prov->>TR: GetByTenantID(tenant)
TR-->>Prov: Tenant{UIDPrefix}
Prov->>UID: Next(tenant, prefix)
UID->>Redis: INCR member:seq:{tenant}
UID-->>Prov: "ACME-10000003"
Prov->>MR: Insert(Member{status=active, origin=oidc, zitadel_user_id})
MR->>Mongo: insertOne
alt duplicate(競態)
MR-->>Prov: ErrDuplicateMember
Prov->>MR: GetByZitadelUserID // 再讀一次回傳
end
Prov->>IR: Insert(Identity{zitadel_user_id, uid})
IR->>Mongo: insertOne(忽略 dup)
Prov-->>Logic: MemberDTO
end
```
LDAP / SCIM 同樣模式,額外查 `IdentityRepository.GetByExternalID` 處理沒有 zitadel_sub 的情境。
### 5. 業務 Email / Phone OTP 驗證
`internal/logic/member/verify_helper.go` 編排(`startVerification` + `confirmVerification`),展示 logic 層如何把多個 atomic usecase 串起來。
```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
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
```
**關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門。
### 6. TOTP 綁定 / Step-up
```mermaid
sequenceDiagram
autonumber
participant Client
participant Logic participant Logic
participant TOTP as TOTPUseCase participant TOTP as TOTPUseCase
participant Redis participant Profile as TOTPProfileRepository
participant Profile participant Enroll as TOTPEnrollStore
participant Replay as TOTPReplayStore
participant Cipher as crypto.Cipher (AES-GCM)
Note over Logic,Profile: 綁定階段 Note over Client,Cipher: A. 綁定階段
Client->>Logic: POST /me/totp/enroll
Logic->>TOTP: StartEnroll(tenant, uid, account) Logic->>TOTP: StartEnroll(tenant, uid, account)
TOTP->>Redis: Save encrypted staged secret TOTP->>Profile: Get → 必須未 enrolled
TOTP-->>Logic: otpauth_url (QR code) TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte)
User->>User: 掃碼加入 Authenticator 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) Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
TOTP->>Profile: Save encrypted secret + backup hashes TOTP->>Enroll: Get → cipherBlob
TOTP->>Redis: Delete staged secret TOTP->>Cipher: Decrypt → secret
TOTP-->>Logic: backup_codes[] (只顯示一次) 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 Logic,Profile: 日常使用step-up Note over Client,Replay: B. 日常 step-up
Client->>Logic: 任意敏感操作攜 6 碼
Logic->>TOTP: VerifyCode(tenant, uid, code) Logic->>TOTP: VerifyCode(tenant, uid, code)
TOTP->>Profile: Decrypt secret TOTP->>Profile: Get → 必須 enrolled
TOTP->>Redis: MarkUsed(timestep) — 防重放 TOTP->>Cipher: Decrypt(SecretCipher)
TOTP-->>Logic: ok / err 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
``` ```
### API ### 7. UID 生成
```go ```mermaid
// 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code sequenceDiagram
start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com") autonumber
// start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn participant Caller as Lifecycle / Provisioning
participant Gen as UIDGenerator
participant Redis
// 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼 Caller->>Gen: Next(tenant, uidPrefix)
backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913") Gen->>Redis: INCR member:seq:{tenant}
// backupCodes 只回傳這一次,請引導使用者妥善保存 Redis-->>Gen: seq
alt seq == 1 (首次)
// 3. step-up 驗碼 — TOTP 或備援碼皆可 Note right of Gen: 一次補上起始值<br/>(避開像 ACME-1 這種短 UID)
err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP Gen->>Redis: INCRBY (UIDSequenceStart - 1) = 9_999_999
err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪) Redis-->>Gen: 10_000_000
end
// 4. 查狀態 Gen-->>Caller: "{PREFIX}-{seq}" 例:ACME-10000003
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")
``` ```
### VerifyCode 判定順序 `UIDSequenceStart = 10_000_000`(7 位起跳),`UIDPrefix` 限制 2~4 個大寫字母。
1. 長度 = 6 → 當 TOTP 驗(含 ±window
2. 通過 → Redis 記錄 time step已用過則回 `ErrTOTPCodeReplay`
3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
4. 皆失敗 → `ErrTOTPInvalidCode`
---
## 設定
`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 keyhex 64 字元或 base64留空則不啟用 TOTP
```
`SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入production 建議走 KMS / secret manager
---
## 裝配與注入
### Module factory
```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 — 預設 memoryP4 換 Mongo
```
### ServiceContext
Gateway 啟動時Redis 就緒)自動注入:
```go
svc.MemberOTP // domusecase.OTPUseCase
svc.MemberTOTP // domusecase.TOTPUseCase可能 nil
svc.MemberVerifyRate // VerifyRateStore
svc.MemberProfile // ProfileRepository
```
---
## Logic 層編排範例
以下示範 **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, &notif.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)
```
`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
```
--- ---
## Redis Key 命名 ## Redis Key 命名
| Key 前綴 | 用途 | | Helper | 對應 key | 使用者 |
|----------|------| | --- | --- | --- |
| `member:otp:challenge:{id}` | OTP challenge 狀態 | | `GetOTPChallengeRedisKey(id)` | `member:otp:challenge:{id}` | `OTPChallengeStore` |
| `member:otp:challenge:{id}:attempts` | 錯誤次數計數 | | `GetOTPAttemptsRedisKey(id)` | `member:otp:challenge:{id}:attempts` | `OTPChallengeStore` |
| `member:verify:rate:{tenant}:{uid}:{kind}` | 重發冷卻 | | `GetVerifyRateRedisKey(tenant, uid, kind)` | `member:verify:rate:...` | `VerifyRate` (logic 層) |
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 | | `GetVerifyDailyRedisKey(tenant, uid, kind)` | `member:verify:daily:...` | 同上 |
| `member:totp:enroll:{tenant}:{uid}` | 綁定 staged secret | | `GetTOTPEnrollRedisKey(tenant, uid)` | `member:totp:enroll:...` | `TOTPEnrollStore` |
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | | `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")
}
```
--- ---
## 測試 ## 測試
### 本機 APIP4
> 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 ```bash
@ -339,45 +584,48 @@ go test ./internal/model/member/... -v
make check make check
``` ```
### 互動式 TOTPGoogle 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 ```bash
make deps-up make deps-up
make totp-test make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放
```
流程(單一 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=status make totp-test STEP=status
make totp-test STEP=disable make totp-test STEP=disable
make totp-test STEP=verify CODE=482913
``` ```
| 檔案 | 覆蓋 | 需在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
|------|------|
| `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 加解密 |
--- ---
## 尚未實作 ## 設計參考
- HTTP API / goctl handlerverify-email、verify-phone、totp enroll 等) - 詳細領域模型 / 多租戶設計 / B2B Permission 對接:`docs/identity-member-design.md`
- Logic 層 confirm 流程Verify + Profile flip + rate limit - 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1
- `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory - 統一錯誤格式(`errb.*`):`internal/library/errors/README.md`
- Step-up token 簽發auth 模組)
設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。

View File

@ -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<br/>Redis adapter)]
Casbin -- Check --> Middleware[CasbinRBAC Middleware]
```
- Permission **平台 seed 全局**`cmd/permission-seed`),租戶不可新增;只能勾選。
- Role / RolePermission / UserRole **租戶獨立**;同名 role 可在不同租戶共存。
- Role.Key 一旦建立 **不可改**;外部 IdPZITADEL / LDAP / SCIM以 Key 作對應。
- 多 pod 同步:**Redis Pub/Sub 即時通知 + 5min cron 兜底**。
---
## 1. 核心概念
| 概念 | 簡述 | 關鍵欄位 |
|------|------|----------|
| **Permission** | 平台級權限節點樹狀dot notation | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy |
| **Role** | 租戶內的角色 | `tenant_id + key` 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 clientgo-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 = nilCheck 永遠 deny
end
Mod->>Mod: New {Permission, Role, RolePermission, UserRole, RoleMapping, AuthorizationQuery}
Mod-->>Boot: *Module7 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: okfire-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<br/>※ source=manual 紀錄不動
UC->>RBAC: BroadcastReload(tenant)
```
### 6.7 LoadPolicyCasbin 規則載入)
```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 rolesany-allow
RBAC->>Enf: EnforceEx(tenant, role.key, path, method)
alt allow
RBAC-->>MW: CheckResult{Allow=true, MatchedRoleKey, MatchedPolicyRow}
end
end
MW->>MW: result.Allow ? next : 403 (errs.AuthForbidden)
```
### 6.9 Pub/Sub 多 Pod Reload
```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 enforcerMongo+Redis 全到位才有)
sc.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用
```
未啟用 Casbin 時 `PermissionRBAC == nil``Check()` 永遠 denymiddleware 會拒絕所有請求(除非 `AllowMissingActor=true`)。
---
## 9. HTTP API前綴 `/api/v1/permissions`
| Method | Path | Handler | 說明 |
|--------|------|---------|------|
| GET | `/catalog` | `getPermissionCatalog` | 全局 Catalogtree=true 取樹狀) |
| GET | `/me` | `getMePermissions` | 當前 user 的 role / permission map |
| GET | `/roles` | `listRoles` | 租戶角色清單 |
| POST | `/roles` | `createRole` | 建立角色key 不可改) |
| PATCH | `/roles/:id` | `updateRole` | 更新 display_name / statussystem role 限制) |
| DELETE | `/roles/:id` | `deleteRole` | 刪角色system / 仍有指派 → 拒絕) |
| GET | `/roles/:id/permissions` | `getRolePermissions` | 角色目前的 permission 集合 |
| PUT | `/roles/:id/permissions` | `replaceRolePermissions` | 全量取代 + 補 parent + Pub/Sub reload |
| GET | `/users/:uid/roles` | `listUserRoles` | 使用者目前指派的 role |
| POST | `/users/:uid/roles` | `assignUserRole` | 指派角色source 預設 manual |
| DELETE | `/users/:uid/roles/:role_id` | `revokeUserRole` | 撤銷單一角色 |
| GET | `/role-mappings` | `listRoleMappings` | 外部映射列表(分頁) |
| PUT | `/role-mappings` | `upsertRoleMapping` | Upsert 外部 group → Role.Key |
| DELETE | `/role-mappings` | `deleteRoleMapping` | 刪除外部映射 |
| POST | `/policy/reload` | `reloadPolicy` | 強制重載(單租戶或 `*` |
完整錯誤碼註解參見 `generate/api/permission.api`,由 `make gen-doc` 出 OpenAPI。
---
## 10. 設定範例(`etc/gateway.dev.example.yaml`
```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 rolecatalog 已存在)
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog
# 7) 強制全部 pod 重載 policyHTTP
curl -X POST http://localhost:8888/api/v1/permissions/policy/reload \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: TEN-100001" -H "X-UID: TEN-100001-OWNER" \
-d '{"tenant_id": "*"}'
```
---
## 12. 中介層middleware/casbin_rbac.go
**現況:** middleware 已寫好,但 **尚未掛入 routes.go**(避免影響現有 dev 模式)。要啟用:
```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 一個 enforcerlazy 建 | 比一個 enforcer + filtered policy 簡單,且記憶體可預測 |
| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時通知 + reboot 不漏 |
| Pub/Sub client | 獨立 go-redis不走 go-zero pool | go-zero 沒包 Subscribe且 Subscribe 會佔住 conn |
| Permission Catalog 改動 | seed CLI 即可idempotent | UI 端不直接改 catalogseed JSON 是 SoT |
---
## 15. 後續工作
| 項目 | 預估 |
|------|------|
| Platform admin allowlist + audit log | 後續 |
| RoleMapping 用 SyncFromX 落地Zitadel / LDAP / SCIM| 隨對應 SyncFromX usecase 推進 |
| Policy reload cron worker5 min | 取自 svc 啟動 ticker |
| Role permission 編輯 UI不在 Gateway 內,由前端取資) | 前端 |
| 細粒度欄位過濾(`.plain_code` 變體) | logic 層額外查 sub-permission |

View File

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

View File

@ -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_"}

View File

@ -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 != ""
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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)
}

View File

@ -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)
}

View File

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

View File

@ -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")
)

View File

@ -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()
}

View File

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

View File

@ -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)
}

View File

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

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

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

View File

@ -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()
}

View File

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

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": "健康檢查"
}
]

View File

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

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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,
}
}

View File

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

View File

@ -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 13
// 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)

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,9 @@ import (
memberusecase "gateway/internal/model/member/usecase" memberusecase "gateway/internal/model/member/usecase"
domnotif "gateway/internal/model/notification/domain/usecase" domnotif "gateway/internal/model/notification/domain/usecase"
notifusecase "gateway/internal/model/notification/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" "gateway/internal/worker/notification_retry"
) )
@ -45,6 +48,16 @@ type ServiceContext struct {
MemberTenant dommember.TenantUseCase MemberTenant dommember.TenantUseCase
MemberVerifyRate dommember.VerifyRateUseCase MemberVerifyRate dommember.VerifyRateUseCase
MemberRepo domrepo.MemberRepository 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 { func NewServiceContext(c config.Config) *ServiceContext {
@ -126,6 +139,25 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.MemberVerifyRate = memberMod.VerifyRate sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberRepo = memberMod.Members 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 return sc
} }
@ -133,10 +165,18 @@ func (sc *ServiceContext) StartWorkers(ctx context.Context) {
if sc.NotificationRetry != nil { if sc.NotificationRetry != nil {
sc.NotificationRetry.Start(ctx) sc.NotificationRetry.Start(ctx)
} }
if sc.permissionModule != nil {
if err := sc.permissionModule.StartBackground(ctx); err != nil {
panic(err)
}
}
} }
func (sc *ServiceContext) StopWorkers() { func (sc *ServiceContext) StopWorkers() {
if sc.NotificationRetry != nil { if sc.NotificationRetry != nil {
sc.NotificationRetry.Stop() sc.NotificationRetry.Stop()
} }
if sc.permissionModule != nil {
sc.permissionModule.StopBackground()
}
} }

View File

@ -9,6 +9,17 @@ type APIErrorStatus struct {
Error ErrorDetail `json:"error"` 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 { type AuthTokenData struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
@ -17,6 +28,32 @@ type AuthTokenData struct {
TokenType string `json:"token_type"` 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 { type ErrorDetail struct {
BizCode string `json:"biz_code"` BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"` Scope uint32 `json:"scope,omitempty"`
@ -24,6 +61,14 @@ type ErrorDetail struct {
Detail uint32 `json:"detail,omitempty"` Detail uint32 `json:"detail,omitempty"`
} }
type GetRolePermissionsByIDReq struct {
ID string `path:"id"`
}
type ListUserRolesReq struct {
UID string `path:"uid"`
}
type LoginReq struct { type LoginReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` TenantSlug string `json:"tenant_slug" validate:"required"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
@ -41,6 +86,12 @@ type LoginSocialStartData struct {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
type LoginSocialStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data LoginSocialStartData `json:"data"`
}
type LoginSocialStartReq struct { type LoginSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` TenantSlug string `json:"tenant_slug" validate:"required"`
Provider string `json:"provider" validate:"required,oneof=google"` Provider string `json:"provider" validate:"required,oneof=google"`
@ -51,6 +102,30 @@ type LogoutData struct {
OK bool `json:"ok"` 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 { type MemberMeData struct {
TenantID string `json:"tenant_id"` TenantID string `json:"tenant_id"`
UID string `json:"uid"` UID string `json:"uid"`
@ -71,6 +146,40 @@ type MemberMeData struct {
UpdateAt int64 `json:"update_at"` 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 { type PingData struct {
Pong string `json:"pong"` Pong string `json:"pong"`
} }
@ -81,6 +190,21 @@ type PingOKStatus struct {
Data PingData `json:"data"` 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 { type RegisterConfirmReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` TenantSlug string `json:"tenant_slug" validate:"required"`
ChallengeID string `json:"challenge_id" validate:"required"` ChallengeID string `json:"challenge_id" validate:"required"`
@ -93,6 +217,12 @@ type RegisterData struct {
UID string `json:"uid"` UID string `json:"uid"`
} }
type RegisterOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RegisterData `json:"data"`
}
type RegisterReq struct { type RegisterReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` TenantSlug string `json:"tenant_slug" validate:"required"`
InviteCode string `json:"invite_code" validate:"required"` InviteCode string `json:"invite_code" validate:"required"`
@ -120,6 +250,12 @@ type RegisterSocialStartData struct {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
type RegisterSocialStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RegisterSocialStartData `json:"data"`
}
type RegisterSocialStartReq struct { type RegisterSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` TenantSlug string `json:"tenant_slug" validate:"required"`
InviteCode string `json:"invite_code" validate:"required"` InviteCode string `json:"invite_code" validate:"required"`
@ -130,14 +266,114 @@ type RegisterSocialStartReq struct {
MarketingOptIn bool `json:"marketing_opt_in,optional"` 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 { type TOTPBackupCodesData struct {
BackupCodes []string `json:"backup_codes"` BackupCodes []string `json:"backup_codes"`
} }
type TOTPBackupCodesOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPBackupCodesData `json:"data"`
}
type TOTPEnrollConfirmData struct { type TOTPEnrollConfirmData struct {
BackupCodes []string `json:"backup_codes"` BackupCodes []string `json:"backup_codes"`
} }
type TOTPEnrollConfirmOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPEnrollConfirmData `json:"data"`
}
type TOTPEnrollConfirmReq struct { type TOTPEnrollConfirmReq struct {
Code string `json:"code"` Code string `json:"code"`
} }
@ -151,6 +387,12 @@ type TOTPEnrollStartData struct {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
type TOTPEnrollStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPEnrollStartData `json:"data"`
}
type TOTPStatusData struct { type TOTPStatusData struct {
Enrolled bool `json:"enrolled"` Enrolled bool `json:"enrolled"`
EnrolledAt int64 `json:"enrolled_at,omitempty"` EnrolledAt int64 `json:"enrolled_at,omitempty"`
@ -159,6 +401,12 @@ type TOTPStatusData struct {
PeriodSeconds int `json:"period_seconds,omitempty"` PeriodSeconds int `json:"period_seconds,omitempty"`
} }
type TOTPStatusOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPStatusData `json:"data"`
}
type TOTPVerifyReq struct { type TOTPVerifyReq struct {
Code string `json:"code"` Code string `json:"code"`
} }
@ -172,6 +420,10 @@ type TokenRefreshReq struct {
RefreshToken string `json:"refresh_token" validate:"required"` RefreshToken string `json:"refresh_token" validate:"required"`
} }
type UIDPath struct {
UID string `path:"uid"`
}
type UpdateMemberMeReq struct { type UpdateMemberMeReq struct {
DisplayName string `json:"display_name,optional"` DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"` Avatar string `json:"avatar,optional"`
@ -180,6 +432,56 @@ type UpdateMemberMeReq struct {
Phone string `json:"phone,optional"` 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 { type VerificationConfirmReq struct {
ChallengeID string `json:"challenge_id"` ChallengeID string `json:"challenge_id"`
Code string `json:"code"` Code string `json:"code"`
@ -190,6 +492,12 @@ type VerificationStartData struct {
ExpiresIn int `json:"expires_in"` ExpiresIn int `json:"expires_in"`
} }
type VerificationStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data VerificationStartData `json:"data"`
}
type VerificationStartReq struct { type VerificationStartReq struct {
Target string `json:"target"` Target string `json:"target"`
} }