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

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!"
RegistrationSessionTTLSeconds: 600
Permission:
Casbin:
Enabled: false # 預設關閉;要啟用 RBAC enforcement 時改 true
ModelPath: etc/rbac.conf
PolicyAdapter: auto # auto / redis / mongo
Cache:
UserRolesTTLSeconds: 300
RolePermsTTLSeconds: 300
CatalogTTLSeconds: 600
Reload:
Channel: casbin:reload
DebounceMilliseconds: 200
HeartbeatSeconds: 60
# ZITADEL identity backend (auth register/login — PR 1+)
# ServiceUserToken: export ZITADEL_SERVICE_TOKEN=...
# OAuthClientSecret: export ZITADEL_OAUTH_CLIENT_SECRET=...

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

@ -1,17 +1,17 @@
syntax = "v1"
info (
title: "Portal-Api-Gateway (PGW)"
desc: "Digimon web portal API gateway"
author: "daniel Wang"
email: "igs170911@gmail.com"
version: "0.0.1"
host: "127.0.0.1:8888"
schemes: "http,https"
consumes: "application/json"
produces: "application/json"
useDefinitions: true
bizCodeEnumDescription: "102000-成功<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)"
title: "Portal-Api-Gateway (PGW)"
desc: "Digimon web portal API gateway"
author: "daniel Wang"
email: "igs170911@gmail.com"
version: "0.0.1"
host: "127.0.0.1:8888"
schemes: "http,https"
consumes: "application/json"
produces: "application/json"
useDefinitions: true
bizCodeEnumDescription: "102000-成功<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 (
@ -19,5 +19,6 @@ import (
"common.api"
"member.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/smithy-go v1.22.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect

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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@ -45,6 +51,7 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -170,6 +177,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@ -198,6 +206,7 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

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

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"
member "gateway/internal/handler/member"
normal "gateway/internal/handler/normal"
permission "gateway/internal/handler/permission"
"gateway/internal/svc"
"github.com/zeromicro/go-zero/rest"
@ -36,6 +37,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/login/social/start",
Handler: auth.LoginSocialStartHandler(serverCtx),
},
{
// 登出(撤銷 access JWT 及配對 refresh JWT
Method: http.MethodPost,
Path: "/logout",
Handler: auth.LogoutHandler(serverCtx),
},
{
// Email 註冊(建立 ZITADEL + member寄 registration OTP
Method: http.MethodPost,
@ -66,12 +73,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/register/social/start",
Handler: auth.RegisterSocialStartHandler(serverCtx),
},
{
// 登出(撤銷 access JWT 及配對 refresh JWT
Method: http.MethodPost,
Path: "/logout",
Handler: auth.LogoutHandler(serverCtx),
},
{
// ZITADEL id_token 換 CloudEP JWT企業 SSO
Method: http.MethodPost,
@ -178,4 +179,100 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"),
rest.WithTimeout(3000*time.Millisecond),
)
server.AddRoutes(
[]rest.Route{
{
// 取得全局 Permission Catalog樹狀或扁平可篩 status/type
Method: http.MethodGet,
Path: "/catalog",
Handler: permission.GetPermissionCatalogHandler(serverCtx),
},
{
// 取得當前使用者的 role / permission map前端渲染選單
Method: http.MethodGet,
Path: "/me",
Handler: permission.GetMePermissionsHandler(serverCtx),
},
{
// 強制重載 Casbin policy單租戶或所有租戶同步 + Pub/Sub broadcast
Method: http.MethodPost,
Path: "/policy/reload",
Handler: permission.ReloadPolicyHandler(serverCtx),
},
{
// 列出外部來源 → 內部 role 的映射zitadel / ldap / scim
Method: http.MethodGet,
Path: "/role-mappings",
Handler: permission.ListRoleMappingsHandler(serverCtx),
},
{
// Upsert 外部 IdP 群組到內部 role 的映射
Method: http.MethodPut,
Path: "/role-mappings",
Handler: permission.UpsertRoleMappingHandler(serverCtx),
},
{
// 刪除外部 → 內部 role 映射
Method: http.MethodDelete,
Path: "/role-mappings",
Handler: permission.DeleteRoleMappingHandler(serverCtx),
},
{
// 列出租戶內所有角色(含 system role
Method: http.MethodGet,
Path: "/roles",
Handler: permission.ListRolesHandler(serverCtx),
},
{
// 建立租戶自訂角色key 不可改、不可使用 system./platform_ 開頭)
Method: http.MethodPost,
Path: "/roles",
Handler: permission.CreateRoleHandler(serverCtx),
},
{
// 更新角色display_name / 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
Member Scope = 29
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.
type Client struct {
r *redis.Redis
pubSubFields
}
// 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 | 用途 | 典型場景 |
|---------|------|----------|
| **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 |
| **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA |
> **架構原則**(`docs/model.md` §6.1):
> usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。
> 本 module 所有 usecase 都是 **atomic primitives**
> **架構原則**usecase **不可**呼叫其他 usecase。
> 「產碼 → 寄信/簡訊 → 驗碼 → 更新 profile」這類多步驟流程**logic 層**(或 CLI driver編排。
> 詳見 [`docs/model.md`](../../../docs/model.md) §6.1。
---
## 目錄
- [核心概念](#核心概念)
- [目錄結構](#目錄結構)
- [Module 結構與依賴](#module-結構與依賴)
- [Atomic UseCase 一覽](#atomic-usecase-一覽)
- [資料儲存](#資料儲存)
- [生命週期與狀態機](#生命週期與狀態機)
- [核心流程時序圖](#核心流程時序圖)
- [1. 模組裝配 (NewModuleFromParam)](#1-模組裝配-newmodulefromparam)
- [2. Tenant 建立](#2-tenant-建立)
- [3. Platform 註冊 (auth + member.Lifecycle)](#3-platform-註冊-auth--memberlifecycle)
- [4. Provisioning — OIDC / LDAP / SCIM](#4-provisioning--oidc--ldap--scim)
- [5. 業務 Email / Phone OTP 驗證](#5-業務-email--phone-otp-驗證)
- [6. TOTP 綁定 / Step-up](#6-totp-綁定--step-up)
- [7. UID 生成](#7-uid-生成)
- [Redis Key 命名](#redis-key-命名)
- [設定](#設定)
- [ServiceContext 注入](#servicecontext-注入)
- [測試](#測試)
---
## 核心概念
| 實體 | 用途 | 主要欄位 | 儲存 |
| --- | --- | --- | --- |
| **Tenant** | 租戶元資料 | `tenant_id`、`slug`、`uid_prefix`、`status`、`org_id` | Mongo `tenants` |
| **Member** | 會員 profile(租戶範圍) | `tenant_id`+`uid`、`zitadel_user_id`、`status`、`origin`、business email/phone、TOTP cipher | Mongo `members` |
| **Identity** | 外部 ID → UID 對映表 | `zitadel_user_id`、`external_id`、`uid` | Mongo `identities` |
**Member 雙鍵**:`(tenant_id, uid)` 為對外的可讀主鍵;`zitadel_user_id` 是 OIDC 來源的對映鍵。
**多租戶等級**:每個 Member 必屬於一個 Tenant,UID 用 `{TenantUIDPrefix}-{Sequence}` 格式(例:`ACME-10000003`)。
### 來源(Origin)
```
platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified)
oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC)
ldap // Directory Sync(Provisioning.EnsureFromLDAP)
scim // SCIM 2.0(Provisioning.EnsureFromSCIM)
```
---
@ -17,321 +57,526 @@ Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫
```
internal/model/member/
├── config/ # OTP / TOTP 設定
├── domain/ # 介面、enum、errors、redis key
│ ├── enum/
│ ├── repository/
│ └── usecase/
├── repository/ # Redis / memory 實作
├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library
├── usecase/ # OTPUseCase、TOTPUseCase 實作
└── README.md
├── config/ # OTP / TOTP / Registration 設定
├── domain/ # 介面、enum、entity、errors、redis key helper
│ ├── const.go # BSON 欄位、UID 常數
│ ├── entity/ # Member、Tenant、Identity Mongo doc
│ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind
│ ├── errors.go # ErrNotFound、ErrDuplicateMember 等
│ ├── redis.go # GetOTPChallengeRedisKey 等 helper
│ ├── repository/ # 7 個 repository 介面
│ └── usecase/ # 7 個 usecase 介面 + DTO
├── repository/ # Mongo / Redis 實作
├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL)
├── usecase/ # 7 個 usecase 實作 + module factory + mapper
└── README.md # 本檔
```
`domain/` 純介面 + 常數,**不依賴外部 lib**(除 `bson.ObjectID`)。
`usecase/` 只依賴 `domain/`
`repository/` 依賴 `library/mongo`、`library/redis`。
---
## OTPOne-Time Password
## Module 結構與依賴
### 原理
```mermaid
flowchart TB
Logic["logic 層<br/>(handler 編排)"]
1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 RedisTTL 預設 300 秒。
2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次logic 層負責呼叫 `notification.Notifier.Send` 投遞。
3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt成功後 **刪除 challenge**(一次性)。
4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5即鎖定該 challenge。
subgraph M["member.Module (atomic usecases)"]
direction LR
OTP["OTP"]
TOTP["TOTP"]
Profile["Profile"]
Lifecycle["Lifecycle"]
Provisioning["Provisioning"]
Tenant["Tenant"]
VerifyRate["VerifyRate"]
end
subgraph R["domain.Repository (介面)"]
MemberRepo["MemberRepository"]
TenantRepo["TenantRepository"]
IdentityRepo["IdentityRepository"]
OTPStore["OTPChallengeStore"]
RateStore["VerifyRateStore"]
TOTPProf["TOTPProfileRepository"]
TOTPEnroll["TOTPEnrollStore"]
TOTPReplay["TOTPReplayStore"]
UIDGen["UIDGenerator"]
end
subgraph I["repository/ 實作"]
Mongo[(MongoDB)]
Redis[(Redis)]
end
Logic -->|單呼叫| M
OTP --> OTPStore
TOTP --> TOTPProf
TOTP --> TOTPEnroll
TOTP --> TOTPReplay
Profile --> MemberRepo
Lifecycle --> MemberRepo
Lifecycle --> TenantRepo
Lifecycle --> UIDGen
Provisioning --> MemberRepo
Provisioning --> IdentityRepo
Provisioning --> TenantRepo
Provisioning --> UIDGen
Tenant --> TenantRepo
VerifyRate --> RateStore
MemberRepo --- Mongo
TenantRepo --- Mongo
IdentityRepo --- Mongo
TOTPProf --- Mongo
OTPStore --- Redis
RateStore --- Redis
TOTPEnroll --- Redis
TOTPReplay --- Redis
UIDGen --- Redis
```
**注入規則**:Module factory 依條件啟用 usecase:
- `Redis` 必填 → `OTP`、`VerifyRate` 永遠存在。
- `MongoConf` 設定 → 啟用 `Profile`、`Lifecycle`、`Tenant`、`Provisioning`。
- `TOTP.SecretKEK` 設定 → 啟用 `TOTP`(否則 `mod.TOTP == nil`)。
---
## Atomic UseCase 一覽
| UseCase | 介面方法 | 職責 |
| --- | --- | --- |
| **TenantUseCase** | `Create` / `ResolveBySlug` | 建立租戶、依 slug 反查 |
| **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換(嚴格的 from→to 檢查) |
| **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | 讀取 / patch 可變欄位、業務 contact 標記已驗證 |
| **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登/同步 upsert(Member + Identity) |
| **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 產出/驗證一次性數字碼(bcrypt + Redis) |
| **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) |
| **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | OTP 重發冷卻 + 每日上限 |
---
## 資料儲存
### MongoDB Collections
| Collection | Entity | 主要索引 |
| --- | --- | --- |
| `members` | `Member` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)`(sparse) |
| `tenants` | `Tenant` | unique `slug`、unique `uid_prefix` |
| `identities` | `Identity` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` |
索引建立由 `repository.EnsureMongoIndexes` 在啟動時執行(對應 `cmd/mongo-index`)。
### Redis Keys
| Key 前綴 | 用途 | TTL |
| --- | --- | --- |
| `member:otp:challenge:{id}` | OTP challenge 主紀錄(bcrypt hash) | `OTP.TTLSeconds`(預設 300) |
| `member:otp:challenge:{id}:attempts` | OTP 錯誤次數計數 | 同 challenge |
| `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 lock | `OTP.ResendCooldownSeconds`(預設 60) |
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限計數 | 24h |
| `member:totp:enroll:{tenant}:{uid}` | 綁定中的 staged secret(AES-GCM cipher) | `TOTP.EnrollTTLSeconds`(預設 600) |
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | `TOTP.ReplayTTLSeconds`(預設 90) |
| `member:seq:{tenant}` | UID 序號(`INCR`) | 永久 |
Helper 函式見 `domain/redis.go`,**禁止** 在他處字串拼接 key。
---
## 生命週期與狀態機
```mermaid
stateDiagram-v2
[*] --> unverified: Lifecycle.CreateUnverified<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
sequenceDiagram
participant Logic
participant OTP as OTPUseCase
participant Redis
participant Notifier
autonumber
participant SVC as svc.NewServiceContext
participant Mod as member.NewModuleFromParam
participant Repo as repository
participant Redis
participant Mongo
Logic->>OTP: Generate(tenant, uid, purpose, target)
OTP->>Redis: Save challenge (bcrypt hash)
OTP-->>Logic: challenge_id, plainCode
Logic->>Notifier: Send(code, expires_in)
Note over Logic: 使用者收到信/簡訊
Logic->>OTP: Verify(challenge_id, code, uid, purpose)
OTP->>Redis: Get + bcrypt compare
OTP->>Redis: Delete challenge
OTP-->>Logic: target (email/phone)
Logic->>Logic: Profile.SetBusinessEmailVerified(...)
SVC->>Mod: ModuleParam{Redis, MongoConf, Config}
Mod->>Repo: NewRedisOTPChallengeStore(redis)
Mod->>Repo: NewRedisVerifyRateStore(redis)
alt MongoConf.Host != ""
Mod->>Repo: NewMemberRepository / NewTenantRepository / NewIdentityRepository
Mod->>Repo: NewMongoTOTPProfileRepository
Repo->>Mongo: ping (lazy)
end
Mod->>Repo: NewRedisUIDGenerator(redis)
Mod->>Mod: MustOTPUseCase / MustVerifyRateUseCase
alt Mongo 就緒
Mod->>Mod: MustProfileUseCase / MustLifecycleUseCase / MustTenantUseCase / MustProvisioningUseCase
end
alt TOTP.SecretKEK != ""
Mod->>Mod: NewAESGCMFromString(KEK)
Mod->>Repo: NewRedisTOTPEnrollStore / NewRedisTOTPReplayStore
Mod->>Mod: MustTOTPUseCase
end
Mod-->>SVC: *Module(7 usecase + 3 repo)
SVC->>SVC: sc.MemberOTP / sc.MemberLifecycle / ...
```
### Purpose用途標籤
```go
enum.OTPPurposeBusinessEmail // 業務 email 驗證
enum.OTPPurposeBusinessPhone // 業務 phone 驗證
enum.OTPPurposeStepUp // step-up未來 logic 層使用)
```
Verify 時 `Purpose` 必須與 Generate 一致,否則拒絕。
### API
```go
// 產碼
dto, plainCode, err := otpUC.Generate(ctx, &domusecase.GenerateOTPRequest{
TenantID: "t1",
UID: "u1",
Purpose: enum.OTPPurposeBusinessEmail,
Target: "user@example.com",
})
// dto.ChallengeID → 給前端帶回 confirm API
// plainCode → 只在此刻存在,交給 Notifier 寄出
// 驗碼
target, err := otpUC.Verify(ctx, &domusecase.VerifyOTPRequest{
TenantID: "t1",
UID: "u1",
ChallengeID: dto.ChallengeID,
Code: "482913",
Purpose: enum.OTPPurposeBusinessEmail,
})
// 成功 → target == "user@example.com"challenge 已刪除
// 寄送失敗時回滾
_ = otpUC.Invalidate(ctx, dto.ChallengeID)
```
### Rate 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**同一時間窗口內不可重放**。
### 2. Tenant 建立
```mermaid
sequenceDiagram
participant User
participant Logic
participant TOTP as TOTPUseCase
participant Redis
participant Profile
autonumber
participant CLI as cmd/member-seed
participant TenantUC as TenantUseCase
participant Repo as TenantRepository
participant Mongo
Note over Logic,Profile: 綁定階段
Logic->>TOTP: StartEnroll(tenant, uid, account)
TOTP->>Redis: Save encrypted staged secret
TOTP-->>Logic: otpauth_url (QR code)
User->>User: 掃碼加入 Authenticator
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
TOTP->>Profile: Save encrypted secret + backup hashes
TOTP->>Redis: Delete staged secret
TOTP-->>Logic: backup_codes[] (只顯示一次)
Note over Logic,Profile: 日常使用step-up
Logic->>TOTP: VerifyCode(tenant, uid, code)
TOTP->>Profile: Decrypt secret
TOTP->>Redis: MarkUsed(timestep) — 防重放
TOTP-->>Logic: ok / err
CLI->>TenantUC: Create(req{TenantID, Slug, Name, UIDPrefix})
TenantUC->>TenantUC: normalizeUIDPrefix + 長度檢查 (2-4)
TenantUC->>Repo: GetByUIDPrefix(prefix)
Repo->>Mongo: findOne
alt prefix 已存在
TenantUC-->>CLI: ErrAlreadyExist("uid_prefix already exists")
else 不存在
TenantUC->>Repo: Insert(Tenant{Status: active})
Repo->>Mongo: insertOne
TenantUC-->>CLI: TenantDTO
end
```
### API
### 3. Platform 註冊 (auth + member.Lifecycle)
```go
// 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code
start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com")
// start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn
> 屬於 `internal/logic/auth/register_logic.go` 的編排;Member module 只負責 atomic 動作。
// 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼
backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913")
// backupCodes 只回傳這一次,請引導使用者妥善保存
```mermaid
sequenceDiagram
autonumber
participant Client
participant RegLogic as logic/auth.RegisterLogic
participant TenantUC as TenantUseCase
participant Zitadel as library/zitadel
participant Lifecycle as LifecycleUseCase
participant OTP as OTPUseCase
participant Notifier
participant Confirm as logic/auth.RegisterConfirmLogic
// 3. step-up 驗碼 — TOTP 或備援碼皆可
err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP
err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪)
Client->>RegLogic: POST /auth/register {tenant_slug, email, password}
RegLogic->>TenantUC: ResolveBySlug(slug)
TenantUC-->>RegLogic: TenantDTO
RegLogic->>Zitadel: CreateHumanUser(...)
Zitadel-->>RegLogic: zitadel_user_id
RegLogic->>Lifecycle: CreateUnverified(req{tenant, email, hash, zitadel_user_id})
Lifecycle->>Lifecycle: 取 tenant.UIDPrefix → UIDGenerator.Next
Lifecycle->>Lifecycle: members.Insert(status=unverified)
Lifecycle-->>RegLogic: MemberDTO(uid)
RegLogic->>OTP: Generate(purpose=Register, uid, target=email)
OTP-->>RegLogic: challenge_id, plainCode
RegLogic->>Notifier: Send(VerifyEmail, code)
alt Notifier 失敗
RegLogic->>Lifecycle: AbortPending(uid)
RegLogic-->>Client: 5xx
else 成功
RegLogic-->>Client: {challenge_id, expires_in}
end
// 4. 查狀態
status, err := totpUC.Status(ctx, "t1", "u1")
// status.Enrolled, status.BackupCodesRemaining
// 5. 停用 / 重產備援碼logic 層應先要求 step-up
_ = totpUC.Disable(ctx, "t1", "u1")
newCodes, err := totpUC.RegenerateBackupCodes(ctx, "t1", "u1")
Note over Client,Confirm: 使用者收到信
Client->>Confirm: POST /auth/register/confirm {challenge_id, code}
Confirm->>OTP: MatchChallenge(challenge_id, tenant, purpose=Register, RequireUID)
OTP-->>Confirm: OTPChallengeInfo{uid}
Confirm->>OTP: Verify(challenge_id, code, uid, purpose)
OTP-->>Confirm: target(email)
Confirm->>Lifecycle: Activate(tenant, uid) // unverified → active
Confirm-->>Client: JWT (auth 簽發)
```
### VerifyCode 判定順序
### 4. Provisioning — OIDC / LDAP / SCIM
1. 長度 = 6 → 當 TOTP 驗(含 ±window
2. 通過 → Redis 記錄 time step已用過則回 `ErrTOTPCodeReplay`
3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
4. 皆失敗 → `ErrTOTPInvalidCode`
外部身份首次登入時透過 `EnsureFromOIDC` upsert,**冪等**(既存即回傳)。
---
```mermaid
sequenceDiagram
autonumber
participant Logic as logic/auth.LoginSocialCallback
participant Prov as ProvisioningUseCase
participant MR as MemberRepository
participant IR as IdentityRepository
participant TR as TenantRepository
participant UID as UIDGenerator
participant Redis
participant Mongo
## 設定
`etc/gateway.dev.yaml``Member` 區塊:
```yaml
Member:
OTP:
Length: 6 # 驗證碼位數
TTLSeconds: 300 # challenge 存活時間
MaxAttempts: 5 # 單 challenge 最大錯誤次數
ResendCooldownSeconds: 60 # 重發冷卻logic 層用 VerifyRateStore
DailyVerifyLimit: 10 # 每日上限logic 層用 VerifyRateStore
TOTP:
Issuer: CloudEP
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1 # ±1 time step
BackupCodeCount: 10
BackupCodeLength: 12
EnrollTTLSeconds: 600 # 綁定 staged secret TTL
ReplayTTLSeconds: 90 # 重放保護 TTL
SecretKEK: "" # 32-byte AES keyhex 64 字元或 base64留空則不啟用 TOTP
Logic->>Prov: EnsureFromOIDC(tenant, zitadel_sub, email, ...)
Prov->>MR: GetByZitadelUserID(tenant, sub)
MR->>Mongo: find
alt 已存在
MR-->>Prov: Member
Prov-->>Logic: MemberDTO (origin=oidc, status=active)
else ErrNotFound
Prov->>TR: GetByTenantID(tenant)
TR-->>Prov: Tenant{UIDPrefix}
Prov->>UID: Next(tenant, prefix)
UID->>Redis: INCR member:seq:{tenant}
UID-->>Prov: "ACME-10000003"
Prov->>MR: Insert(Member{status=active, origin=oidc, zitadel_user_id})
MR->>Mongo: insertOne
alt duplicate(競態)
MR-->>Prov: ErrDuplicateMember
Prov->>MR: GetByZitadelUserID // 再讀一次回傳
end
Prov->>IR: Insert(Identity{zitadel_user_id, uid})
IR->>Mongo: insertOne(忽略 dup)
Prov-->>Logic: MemberDTO
end
```
`SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入production 建議走 KMS / secret manager
LDAP / SCIM 同樣模式,額外查 `IdentityRepository.GetByExternalID` 處理沒有 zitadel_sub 的情境
---
### 5. 業務 Email / Phone OTP 驗證
## 裝配與注入
`internal/logic/member/verify_helper.go` 編排(`startVerification` + `confirmVerification`),展示 logic 層如何把多個 atomic usecase 串起來。
### Module factory
```mermaid
sequenceDiagram
autonumber
participant Client
participant Logic as logic/member.startVerification
participant Rate as VerifyRateUseCase
participant OTP as OTPUseCase
participant Notif as Notifier
participant Profile as ProfileUseCase
participant Redis
```go
mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
Redis: rds,
Config: c.Member,
})
// mod.OTP — 永遠有值(需 Redis
// mod.TOTP — SecretKEK 有設定時才有值,否則 nil
// mod.VerifyRate — resend / daily cap
// mod.Profile — 預設 memoryP4 換 Mongo
Client->>Logic: POST /me/verifications/email/start {target}
Logic->>Rate: AssertResendAllowed(rateKey, cooldown=60s)
Rate->>Redis: SETNX member:verify:rate:{t}:{u}:business_email
alt cooldown 中
Rate-->>Logic: ErrTooManyRequest
Logic-->>Client: 429
end
Logic->>Rate: AssertDailyAllowed(dailyKey, 24h, limit=10)
Rate->>Redis: INCR member:verify:daily:{t}:{u}:business_email
Logic->>OTP: Generate(uid, purpose=BusinessEmail, target=email)
OTP->>Redis: SET member:otp:challenge:{id} (bcrypt hash, TTL=300s)
OTP-->>Logic: challenge_id, plainCode
Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in})
alt Notifier 失敗
Logic->>OTP: Invalidate(challenge_id)
Logic-->>Client: 5xx
else 成功
Logic-->>Client: {challenge_id, expires_in}
end
Note over Client,Profile: 使用者收到信
Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code}
Logic->>OTP: Verify(challenge_id, code, uid, purpose=BusinessEmail)
OTP->>Redis: GET + bcrypt compare
alt 失敗
OTP->>Redis: INCR attempts
alt attempts >= 5
OTP-->>Logic: ErrChallengeLocked
else
OTP-->>Logic: ErrInvalidOTP
end
else 成功
OTP->>Redis: DEL challenge
OTP-->>Logic: target(email)
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
Profile-->>Logic: nil
Logic-->>Client: 204
end
```
### ServiceContext
**關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門。
Gateway 啟動時Redis 就緒)自動注入:
### 6. TOTP 綁定 / Step-up
```go
svc.MemberOTP // domusecase.OTPUseCase
svc.MemberTOTP // domusecase.TOTPUseCase可能 nil
svc.MemberVerifyRate // VerifyRateStore
svc.MemberProfile // ProfileRepository
```mermaid
sequenceDiagram
autonumber
participant Client
participant Logic
participant TOTP as TOTPUseCase
participant Profile as TOTPProfileRepository
participant Enroll as TOTPEnrollStore
participant Replay as TOTPReplayStore
participant Cipher as crypto.Cipher (AES-GCM)
Note over Client,Cipher: A. 綁定階段
Client->>Logic: POST /me/totp/enroll
Logic->>TOTP: StartEnroll(tenant, uid, account)
TOTP->>Profile: Get → 必須未 enrolled
TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte)
TOTP->>Cipher: Encrypt(secret) → cipherBlob
TOTP->>Enroll: Save(cipherBlob, TTL=600s)
TOTP-->>Logic: {otpauth_url, digits=6, period=30}
Logic-->>Client: QR code 資料
Client->>Client: 掃 QR 加入 Authenticator
Client->>Logic: POST /me/totp/enroll/confirm {code}
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
TOTP->>Enroll: Get → cipherBlob
TOTP->>Cipher: Decrypt → secret
TOTP->>TOTP: totp.Verify(secret, code, ±window)
alt 驗碼失敗
TOTP-->>Logic: ErrTOTPInvalidCode
else 成功
TOTP->>TOTP: 產生 N 個 backup codes + bcrypt hashes
TOTP->>Profile: Save({Enrolled, SecretCipher, BackupCodesHash})
TOTP->>Enroll: Delete (清掉 staged)
TOTP-->>Logic: plainCodes[](僅此一次回傳)
end
Note over Client,Replay: B. 日常 step-up
Client->>Logic: 任意敏感操作攜 6 碼
Logic->>TOTP: VerifyCode(tenant, uid, code)
TOTP->>Profile: Get → 必須 enrolled
TOTP->>Cipher: Decrypt(SecretCipher)
alt code 長度 = 6
TOTP->>TOTP: totp.Verify(±window) → step
alt OK
TOTP->>Replay: MarkUsed(timestep, TTL=90s) → fresh?
alt 已用過
TOTP-->>Logic: ErrTOTPCodeReplay
else 未用過
TOTP-->>Logic: nil
end
else 失敗
TOTP->>TOTP: fall through to backup code
end
end
alt 嘗試備援碼
loop 每組 hash
TOTP->>TOTP: bcrypt.CompareHashAndPassword
end
alt 命中
TOTP->>Profile: ConsumeBackupCode(hash) (atomic)
TOTP-->>Logic: nil
else 全失敗
TOTP-->>Logic: ErrTOTPInvalidCode
end
end
```
---
### 7. UID 生成
## Logic 層編排範例
```mermaid
sequenceDiagram
autonumber
participant Caller as Lifecycle / Provisioning
participant Gen as UIDGenerator
participant Redis
以下示範 **verify business email** 完整流程logic 層職責,尚未有 HTTP handler
```go
// ── 發起驗證 ──
dto, code, err := svc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
TenantID: tenant, UID: uid,
Purpose: enum.OTPPurposeBusinessEmail,
Target: email,
})
if err != nil { return err }
_, err = svc.Notifier.Send(ctx, &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)
Caller->>Gen: Next(tenant, uidPrefix)
Gen->>Redis: INCR member:seq:{tenant}
Redis-->>Gen: seq
alt seq == 1 (首次)
Note right of Gen: 一次補上起始值<br/>(避開像 ACME-1 這種短 UID)
Gen->>Redis: INCRBY (UIDSequenceStart - 1) = 9_999_999
Redis-->>Gen: 10_000_000
end
Gen-->>Caller: "{PREFIX}-{seq}" 例:ACME-10000003
```
`cmd/notify-test``startMemberVerify` 實作了發起驗證的前半段Generate + Send可作為 driver 參考:
```bash
make deps-up
make notify-test METHOD=member-email TO=you@example.com
make notify-test METHOD=member-phone PHONE=0912345678
```
`UIDSequenceStart = 10_000_000`(7 位起跳),`UIDPrefix` 限制 2~4 個大寫字母。
---
## Redis Key 命名
| Key 前綴 | 用途 |
|----------|------|
| `member:otp:challenge:{id}` | OTP challenge 狀態 |
| `member:otp:challenge:{id}:attempts` | 錯誤次數計數 |
| `member:verify:rate:{tenant}:{uid}:{kind}` | 重發冷卻 |
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 |
| `member:totp:enroll:{tenant}:{uid}` | 綁定 staged secret |
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 |
| Helper | 對應 key | 使用者 |
| --- | --- | --- |
| `GetOTPChallengeRedisKey(id)` | `member:otp:challenge:{id}` | `OTPChallengeStore` |
| `GetOTPAttemptsRedisKey(id)` | `member:otp:challenge:{id}:attempts` | `OTPChallengeStore` |
| `GetVerifyRateRedisKey(tenant, uid, kind)` | `member:verify:rate:...` | `VerifyRate` (logic 層) |
| `GetVerifyDailyRedisKey(tenant, uid, kind)` | `member:verify:daily:...` | 同上 |
| `GetTOTPEnrollRedisKey(tenant, uid)` | `member:totp:enroll:...` | `TOTPEnrollStore` |
| `GetTOTPUsedRedisKey(tenant, uid, step)` | `member:totp:used:...` | `TOTPReplayStore` |
| `GetMemberSeqRedisKey(tenant)` | `member:seq:{tenant}` | `UIDGenerator` |
Helper 函式見 `domain/redis.go``GetOTPChallengeRedisKey` 等)。
`kind` 通常是 `enum.OTPPurpose` 字串(`business_email`、`business_phone`、`step_up` 等)。
---
## 設定
`etc/gateway.dev.yaml``Member` 區塊:
```yaml
Member:
Registration:
RequireInviteCode: true # 平台註冊是否強制邀請碼
TrustSocialEmailVerified: true # OIDC email_verified=true 時直接 active
OTP:
Length: 6 # 驗證碼位數
TTLSeconds: 300 # challenge 存活時間
MaxAttempts: 5 # 單 challenge 最大錯誤次數
ResendCooldownSeconds: 60 # 重發冷卻
DailyVerifyLimit: 10 # 每日上限
TOTP:
Issuer: CloudEP
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1 # ±1 time step 容忍
BackupCodeCount: 10
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空關閉 TOTP
```
**`SecretKEK`** 可改用環境變數 `TOTP_SECRET_KEK`(prod 建議走 KMS / secret manager)。
---
## ServiceContext 注入
```go
// internal/svc/service_context.go
sc.MemberOTP // domusecase.OTPUseCase (一定有)
sc.MemberVerifyRate // domusecase.VerifyRateUseCase (一定有)
sc.MemberProfile // domusecase.ProfileUseCase (Mongo 設定後)
sc.MemberLifecycle // domusecase.LifecycleUseCase (Mongo 設定後)
sc.MemberTenant // domusecase.TenantUseCase (Mongo 設定後)
sc.MemberProvisioning // domusecase.ProvisioningUseCase(Mongo 設定後)
sc.MemberTOTP // domusecase.TOTPUseCase (TOTP.SecretKEK 設定後;否則 nil)
```
Logic 層使用前務必檢查可能 `nil` 的欄位:
```go
if sc.MemberTOTP == nil {
return errb.SysNotImplemented("member TOTP not configured")
}
```
---
## 測試
### 本機 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
@ -339,45 +584,48 @@ go test ./internal/model/member/... -v
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
make deps-up
make totp-test
```
流程(單一 process預設 `-step flow`
1. 終端機印出 **QR code** 與 **Secret key**
2. 手機 Google Authenticator → 掃描 QR或手動輸入 Secret
3. 輸入 Authenticator 上的 6 碼 → **ConfirmEnroll**(綁定完成,顯示備援碼)
4. 等 code 刷新後再輸入新 6 碼 → **VerifyCode**step-up 驗證)
5. 自動測試重放保護(同一碼再驗應失敗)
進階:
```bash
make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放
make totp-test STEP=status
make totp-test STEP=disable
make totp-test STEP=verify CODE=482913
```
| 檔案 | 覆蓋 |
|------|------|
| `usecase/otp_usecase_test.go` | Generate/Verify、UID mismatch、max attempts lock |
| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
| `library/crypto/aesgcm_test.go` | TOTP secret 加解密 |
需在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
---
## 尚未實作
## 設計參考
- HTTP API / goctl handlerverify-email、verify-phone、totp enroll 等)
- Logic 層 confirm 流程Verify + Profile flip + rate limit
- `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory
- Step-up token 簽發auth 模組)
設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。
- 詳細領域模型 / 多租戶設計 / B2B Permission 對接:`docs/identity-member-design.md`
- 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1
- 統一錯誤格式(`errb.*`):`internal/library/errors/README.md`

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"
domnotif "gateway/internal/model/notification/domain/usecase"
notifusecase "gateway/internal/model/notification/usecase"
dompermrepo "gateway/internal/model/permission/domain/repository"
domperm "gateway/internal/model/permission/domain/usecase"
permusecase "gateway/internal/model/permission/usecase"
"gateway/internal/worker/notification_retry"
)
@ -45,6 +48,16 @@ type ServiceContext struct {
MemberTenant dommember.TenantUseCase
MemberVerifyRate dommember.VerifyRateUseCase
MemberRepo domrepo.MemberRepository
PermissionCatalog domperm.PermissionUseCase
PermissionRole domperm.RoleUseCase
PermissionRolePermission domperm.RolePermissionUseCase
PermissionUserRole domperm.UserRoleUseCase
PermissionRoleMapping domperm.RoleMappingUseCase
PermissionAuthQuery domperm.AuthorizationQueryUseCase
PermissionRBAC domperm.RBACUseCase
PermissionRoleRepo dompermrepo.RoleRepository
permissionModule *permusecase.Module
}
func NewServiceContext(c config.Config) *ServiceContext {
@ -126,6 +139,25 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.MemberVerifyRate = memberMod.VerifyRate
sc.MemberRepo = memberMod.Members
}
if c.Mongo.Host != "" {
permMod, err := permusecase.NewModuleFromParam(permusecase.FactoryParam{
MongoConf: &c.Mongo,
Redis: rds,
Config: c.Permission,
})
if err != nil {
panic(err)
}
sc.PermissionCatalog = permMod.Permission
sc.PermissionRole = permMod.Role
sc.PermissionRolePermission = permMod.RolePermission
sc.PermissionUserRole = permMod.UserRole
sc.PermissionRoleMapping = permMod.RoleMapping
sc.PermissionAuthQuery = permMod.AuthorizationQuery
sc.PermissionRBAC = permMod.RBAC
sc.PermissionRoleRepo = permMod.Roles
sc.permissionModule = permMod
}
return sc
}
@ -133,10 +165,18 @@ func (sc *ServiceContext) StartWorkers(ctx context.Context) {
if sc.NotificationRetry != nil {
sc.NotificationRetry.Start(ctx)
}
if sc.permissionModule != nil {
if err := sc.permissionModule.StartBackground(ctx); err != nil {
panic(err)
}
}
}
func (sc *ServiceContext) StopWorkers() {
if sc.NotificationRetry != nil {
sc.NotificationRetry.Stop()
}
if sc.permissionModule != nil {
sc.permissionModule.StopBackground()
}
}

View File

@ -9,6 +9,17 @@ type APIErrorStatus struct {
Error ErrorDetail `json:"error"`
}
type AssignUserRoleByUIDReq struct {
UID string `path:"uid"`
RoleID string `json:"role_id" validate:"required"`
Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
}
type AssignUserRoleReq struct {
RoleID string `json:"role_id" validate:"required"`
Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
}
type AuthTokenData struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
@ -17,6 +28,32 @@ type AuthTokenData struct {
TokenType string `json:"token_type"`
}
type AuthTokenOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data AuthTokenData `json:"data"`
}
type CreateRoleReq struct {
Key string `json:"key" validate:"required,min=2,max=64"`
DisplayName string `json:"display_name,optional"`
Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
}
type DeleteRoleByIDReq struct {
ID string `path:"id"`
}
type DeleteRoleMappingReq struct {
ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
ExternalKey string `json:"external_key" validate:"required"`
}
type EmptyOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
}
type ErrorDetail struct {
BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"`
@ -24,6 +61,14 @@ type ErrorDetail struct {
Detail uint32 `json:"detail,omitempty"`
}
type GetRolePermissionsByIDReq struct {
ID string `path:"id"`
}
type ListUserRolesReq struct {
UID string `path:"uid"`
}
type LoginReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
Email string `json:"email" validate:"required,email"`
@ -41,6 +86,12 @@ type LoginSocialStartData struct {
ExpiresIn int `json:"expires_in"`
}
type LoginSocialStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data LoginSocialStartData `json:"data"`
}
type LoginSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
Provider string `json:"provider" validate:"required,oneof=google"`
@ -51,6 +102,30 @@ type LogoutData struct {
OK bool `json:"ok"`
}
type LogoutOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data LogoutData `json:"data"`
}
type MePermissionsData struct {
UID string `json:"uid"`
TenantID string `json:"tenant_id"`
Roles []string `json:"roles"`
Permissions map[string]string `json:"permissions"`
Tree []PermissionNode `json:"tree,omitempty"`
}
type MePermissionsOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data MePermissionsData `json:"data"`
}
type MePermissionsQuery struct {
IncludeTree bool `form:"include_tree,optional"`
}
type MemberMeData struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
@ -71,6 +146,40 @@ type MemberMeData struct {
UpdateAt int64 `json:"update_at"`
}
type MemberMeOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data MemberMeData `json:"data"`
}
type PermissionCatalogData struct {
Tree []PermissionNode `json:"tree,omitempty"`
List []PermissionNode `json:"list,omitempty"`
}
type PermissionCatalogOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data PermissionCatalogData `json:"data"`
}
type PermissionCatalogQuery struct {
Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
Tree bool `form:"tree,optional"`
}
type PermissionNode struct {
ID string `json:"id"`
Parent string `json:"parent,omitempty"`
Name string `json:"name"`
HTTPMethods string `json:"http_methods,omitempty"`
HTTPPath string `json:"http_path,omitempty"`
Status string `json:"status"`
Type string `json:"type"`
Children []PermissionNode `json:"children,omitempty"`
}
type PingData struct {
Pong string `json:"pong"`
}
@ -81,6 +190,21 @@ type PingOKStatus struct {
Data PingData `json:"data"`
}
type PolicyReloadData struct {
Tenant string `json:"tenant"`
TS int64 `json:"ts"`
}
type PolicyReloadOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data PolicyReloadData `json:"data"`
}
type PolicyReloadReq struct {
TenantID string `json:"tenant_id,optional"`
}
type RegisterConfirmReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
ChallengeID string `json:"challenge_id" validate:"required"`
@ -93,6 +217,12 @@ type RegisterData struct {
UID string `json:"uid"`
}
type RegisterOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RegisterData `json:"data"`
}
type RegisterReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
InviteCode string `json:"invite_code" validate:"required"`
@ -120,6 +250,12 @@ type RegisterSocialStartData struct {
ExpiresIn int `json:"expires_in"`
}
type RegisterSocialStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RegisterSocialStartData `json:"data"`
}
type RegisterSocialStartReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"`
InviteCode string `json:"invite_code" validate:"required"`
@ -130,14 +266,114 @@ type RegisterSocialStartReq struct {
MarketingOptIn bool `json:"marketing_opt_in,optional"`
}
type ReplaceRolePermissionsByIDReq struct {
ID string `path:"id"`
PermissionIDs []string `json:"permission_ids"`
}
type ReplaceRolePermissionsReq struct {
PermissionIDs []string `json:"permission_ids"`
}
type RevokeUserRoleByIDReq struct {
UID string `path:"uid"`
RoleID string `path:"role_id"`
}
type RoleData struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Key string `json:"key"`
DisplayName string `json:"display_name"`
CreatorUID string `json:"creator_uid,omitempty"`
Status string `json:"status"`
IsSystem bool `json:"is_system"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
type RoleListData struct {
Roles []RoleData `json:"roles"`
}
type RoleListOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RoleListData `json:"data"`
}
type RoleMappingData struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
ExternalSource string `json:"external_source"`
ExternalKey string `json:"external_key"`
InternalRoleID string `json:"internal_role_id"`
InternalRoleKey string `json:"internal_role_key"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
type RoleMappingListData struct {
Mappings []RoleMappingData `json:"mappings"`
Total int64 `json:"total"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
}
type RoleMappingListOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RoleMappingListData `json:"data"`
}
type RoleMappingListQuery struct {
Source string `form:"source,optional" validate:"omitempty,oneof=zitadel ldap scim"`
Offset int64 `form:"offset,optional"`
Limit int64 `form:"limit,optional"`
}
type RoleMappingOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RoleMappingData `json:"data"`
}
type RoleOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RoleData `json:"data"`
}
type RolePermissionsListData struct {
Permissions []PermissionNode `json:"permissions"`
}
type RolePermissionsListOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data RolePermissionsListData `json:"data"`
}
type TOTPBackupCodesData struct {
BackupCodes []string `json:"backup_codes"`
}
type TOTPBackupCodesOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPBackupCodesData `json:"data"`
}
type TOTPEnrollConfirmData struct {
BackupCodes []string `json:"backup_codes"`
}
type TOTPEnrollConfirmOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPEnrollConfirmData `json:"data"`
}
type TOTPEnrollConfirmReq struct {
Code string `json:"code"`
}
@ -151,6 +387,12 @@ type TOTPEnrollStartData struct {
ExpiresIn int `json:"expires_in"`
}
type TOTPEnrollStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPEnrollStartData `json:"data"`
}
type TOTPStatusData struct {
Enrolled bool `json:"enrolled"`
EnrolledAt int64 `json:"enrolled_at,omitempty"`
@ -159,6 +401,12 @@ type TOTPStatusData struct {
PeriodSeconds int `json:"period_seconds,omitempty"`
}
type TOTPStatusOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data TOTPStatusData `json:"data"`
}
type TOTPVerifyReq struct {
Code string `json:"code"`
}
@ -172,6 +420,10 @@ type TokenRefreshReq struct {
RefreshToken string `json:"refresh_token" validate:"required"`
}
type UIDPath struct {
UID string `path:"uid"`
}
type UpdateMemberMeReq struct {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`
@ -180,6 +432,56 @@ type UpdateMemberMeReq struct {
Phone string `json:"phone,optional"`
}
type UpdateRoleByIDReq struct {
ID string `path:"id"`
DisplayName string `json:"display_name,optional"`
Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
}
type UpdateRoleReq struct {
DisplayName string `json:"display_name,optional"`
Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
}
type UpsertRoleMappingReq struct {
ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
ExternalKey string `json:"external_key" validate:"required"`
InternalRoleKey string `json:"internal_role_key" validate:"required"`
}
type UserRoleData struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
RoleID string `json:"role_id"`
RoleKey string `json:"role_key"`
RoleDisplayName string `json:"role_display_name"`
Source string `json:"source"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
type UserRoleIDPath struct {
UID string `path:"uid"`
RoleID string `path:"role_id"`
}
type UserRoleListData struct {
UserRoles []UserRoleData `json:"user_roles"`
}
type UserRoleListOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data UserRoleListData `json:"data"`
}
type UserRoleOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data UserRoleData `json:"data"`
}
type VerificationConfirmReq struct {
ChallengeID string `json:"challenge_id"`
Code string `json:"code"`
@ -190,6 +492,12 @@ type VerificationStartData struct {
ExpiresIn int `json:"expires_in"`
}
type VerificationStartOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data VerificationStartData `json:"data"`
}
type VerificationStartReq struct {
Target string `json:"target"`
}