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:
parent
713a81f70b
commit
fa50c64ee4
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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=...
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,526 @@
|
|||
syntax = "v1"
|
||||
|
||||
type (
|
||||
// ===== Permission catalog =====
|
||||
|
||||
PermissionCatalogQuery {
|
||||
Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
|
||||
Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
|
||||
Tree bool `form:"tree,optional"`
|
||||
}
|
||||
|
||||
PermissionNode {
|
||||
ID string `json:"id"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Name string `json:"name"`
|
||||
HTTPMethods string `json:"http_methods,omitempty"`
|
||||
HTTPPath string `json:"http_path,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Children []PermissionNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
PermissionCatalogData {
|
||||
Tree []PermissionNode `json:"tree,omitempty"`
|
||||
List []PermissionNode `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
// ===== Me permissions =====
|
||||
|
||||
MePermissionsQuery {
|
||||
IncludeTree bool `form:"include_tree,optional"`
|
||||
}
|
||||
|
||||
MePermissionsData {
|
||||
UID string `json:"uid"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Roles []string `json:"roles"`
|
||||
Permissions map[string]string `json:"permissions"`
|
||||
Tree []PermissionNode `json:"tree,omitempty"`
|
||||
}
|
||||
|
||||
// ===== Roles =====
|
||||
|
||||
RoleData {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"display_name"`
|
||||
CreatorUID string `json:"creator_uid,omitempty"`
|
||||
Status string `json:"status"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
RoleListData {
|
||||
Roles []RoleData `json:"roles"`
|
||||
}
|
||||
|
||||
CreateRoleReq {
|
||||
Key string `json:"key" validate:"required,min=2,max=64"`
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
|
||||
}
|
||||
|
||||
UpdateRoleReq {
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
|
||||
}
|
||||
|
||||
UpdateRoleByIDReq {
|
||||
ID string `path:"id"`
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
Status string `json:"status,optional" validate:"omitempty,oneof=open close"`
|
||||
}
|
||||
|
||||
DeleteRoleByIDReq {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
GetRolePermissionsByIDReq {
|
||||
ID string `path:"id"`
|
||||
}
|
||||
|
||||
ReplaceRolePermissionsByIDReq {
|
||||
ID string `path:"id"`
|
||||
PermissionIDs []string `json:"permission_ids"`
|
||||
}
|
||||
|
||||
ListUserRolesReq {
|
||||
UID string `path:"uid"`
|
||||
}
|
||||
|
||||
AssignUserRoleByUIDReq {
|
||||
UID string `path:"uid"`
|
||||
RoleID string `json:"role_id" validate:"required"`
|
||||
Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
|
||||
}
|
||||
|
||||
RevokeUserRoleByIDReq {
|
||||
UID string `path:"uid"`
|
||||
RoleID string `path:"role_id"`
|
||||
}
|
||||
|
||||
// ===== Role permissions =====
|
||||
|
||||
RolePermissionsListData {
|
||||
Permissions []PermissionNode `json:"permissions"`
|
||||
}
|
||||
|
||||
ReplaceRolePermissionsReq {
|
||||
PermissionIDs []string `json:"permission_ids"`
|
||||
}
|
||||
|
||||
// ===== User roles =====
|
||||
|
||||
UIDPath {
|
||||
UID string `path:"uid"`
|
||||
}
|
||||
|
||||
UserRoleData {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
UID string `json:"uid"`
|
||||
RoleID string `json:"role_id"`
|
||||
RoleKey string `json:"role_key"`
|
||||
RoleDisplayName string `json:"role_display_name"`
|
||||
Source string `json:"source"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
UserRoleListData {
|
||||
UserRoles []UserRoleData `json:"user_roles"`
|
||||
}
|
||||
|
||||
AssignUserRoleReq {
|
||||
RoleID string `json:"role_id" validate:"required"`
|
||||
Source string `json:"source,optional" validate:"omitempty,oneof=manual zitadel ldap scim"`
|
||||
}
|
||||
|
||||
UserRoleIDPath {
|
||||
UID string `path:"uid"`
|
||||
RoleID string `path:"role_id"`
|
||||
}
|
||||
|
||||
// ===== Role mappings =====
|
||||
|
||||
RoleMappingData {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
ExternalSource string `json:"external_source"`
|
||||
ExternalKey string `json:"external_key"`
|
||||
InternalRoleID string `json:"internal_role_id"`
|
||||
InternalRoleKey string `json:"internal_role_key"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
RoleMappingListData {
|
||||
Mappings []RoleMappingData `json:"mappings"`
|
||||
Total int64 `json:"total"`
|
||||
Offset int64 `json:"offset"`
|
||||
Limit int64 `json:"limit"`
|
||||
}
|
||||
|
||||
RoleMappingListQuery {
|
||||
Source string `form:"source,optional" validate:"omitempty,oneof=zitadel ldap scim"`
|
||||
Offset int64 `form:"offset,optional"`
|
||||
Limit int64 `form:"limit,optional"`
|
||||
}
|
||||
|
||||
UpsertRoleMappingReq {
|
||||
ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
|
||||
ExternalKey string `json:"external_key" validate:"required"`
|
||||
InternalRoleKey string `json:"internal_role_key" validate:"required"`
|
||||
}
|
||||
|
||||
DeleteRoleMappingReq {
|
||||
ExternalSource string `json:"external_source" validate:"required,oneof=zitadel ldap scim"`
|
||||
ExternalKey string `json:"external_key" validate:"required"`
|
||||
}
|
||||
|
||||
// ===== Policy reload =====
|
||||
|
||||
PolicyReloadReq {
|
||||
TenantID string `json:"tenant_id,optional"`
|
||||
}
|
||||
|
||||
PolicyReloadData {
|
||||
Tenant string `json:"tenant"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// ===== OK envelopes for swagger =====
|
||||
|
||||
PermissionCatalogOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data PermissionCatalogData `json:"data"`
|
||||
}
|
||||
|
||||
MePermissionsOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data MePermissionsData `json:"data"`
|
||||
}
|
||||
|
||||
RoleListOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data RoleListData `json:"data"`
|
||||
}
|
||||
|
||||
RoleOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data RoleData `json:"data"`
|
||||
}
|
||||
|
||||
RolePermissionsListOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data RolePermissionsListData `json:"data"`
|
||||
}
|
||||
|
||||
UserRoleListOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data UserRoleListData `json:"data"`
|
||||
}
|
||||
|
||||
UserRoleOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data UserRoleData `json:"data"`
|
||||
}
|
||||
|
||||
RoleMappingListOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data RoleMappingListData `json:"data"`
|
||||
}
|
||||
|
||||
RoleMappingOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data RoleMappingData `json:"data"`
|
||||
}
|
||||
|
||||
PolicyReloadOKStatus {
|
||||
Code int64 `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data PolicyReloadData `json:"data"`
|
||||
}
|
||||
)
|
||||
|
||||
@server(
|
||||
group: permission
|
||||
prefix: /api/v1/permissions
|
||||
)
|
||||
service gateway {
|
||||
@doc "取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)"
|
||||
/*
|
||||
@respdoc-200 (PermissionCatalogOKStatus) // 成功(code=102000)
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
31601000: (APIErrorStatus) 系統內部錯誤
|
||||
) // 內部錯誤
|
||||
@respdoc-501 (
|
||||
31605000: (APIErrorStatus) Permission 模組未配置
|
||||
) // 未實作
|
||||
*/
|
||||
@handler getPermissionCatalog
|
||||
get /catalog (PermissionCatalogQuery) returns (PermissionCatalogData)
|
||||
|
||||
@doc "取得當前使用者的 role / permission map(前端渲染選單)"
|
||||
/*
|
||||
@respdoc-200 (MePermissionsOKStatus) // 成功(code=102000)
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
|
||||
) // 未授權
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
31601000: (APIErrorStatus) 系統內部錯誤
|
||||
) // 內部錯誤
|
||||
@respdoc-501 (
|
||||
31605000: (APIErrorStatus) Permission 模組未配置
|
||||
) // 未實作
|
||||
*/
|
||||
@handler getMePermissions
|
||||
get /me (MePermissionsQuery) returns (MePermissionsData)
|
||||
|
||||
@doc "列出租戶內所有角色(含 system role)"
|
||||
/*
|
||||
@respdoc-200 (RoleListOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler listRoles
|
||||
get /roles returns (RoleListData)
|
||||
|
||||
@doc "建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)"
|
||||
/*
|
||||
@respdoc-200 (RoleOKStatus) // 成功
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
31101000: (APIErrorStatus) role key 格式或保留字錯誤
|
||||
) // 參數錯誤
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-409 (
|
||||
31303000: (APIErrorStatus) 同名 role 已存在
|
||||
) // 衝突
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler createRole
|
||||
post /roles (CreateRoleReq) returns (RoleData)
|
||||
|
||||
@doc "更新角色(display_name / status;is_system 角色不可改 status)"
|
||||
/*
|
||||
@respdoc-200 (RoleOKStatus) // 成功
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
) // 參數錯誤
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) role 不存在
|
||||
) // 不存在
|
||||
@respdoc-409 (
|
||||
31309000: (APIErrorStatus) 系統角色無法更新此欄位
|
||||
) // 衝突
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler updateRole
|
||||
patch /roles/:id (UpdateRoleByIDReq) returns (RoleData)
|
||||
|
||||
@doc "刪除角色(is_system 不可刪;存在 user 指派時拒絕)"
|
||||
/*
|
||||
@respdoc-200 (EmptyOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) role 不存在
|
||||
) // 不存在
|
||||
@respdoc-409 (
|
||||
31309000: (APIErrorStatus) 系統角色無法刪除
|
||||
31312000: (APIErrorStatus) 角色仍有使用者指派
|
||||
) // 衝突
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler deleteRole
|
||||
delete /roles/:id (DeleteRoleByIDReq)
|
||||
|
||||
@doc "讀取角色目前勾選的 permission 集合"
|
||||
/*
|
||||
@respdoc-200 (RolePermissionsListOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) role 不存在
|
||||
) // 不存在
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler getRolePermissions
|
||||
get /roles/:id/permissions (GetRolePermissionsByIDReq) returns (RolePermissionsListData)
|
||||
|
||||
@doc "全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)"
|
||||
/*
|
||||
@respdoc-200 (EmptyOKStatus) // 成功
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
) // 參數錯誤
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) role 或 permission 不存在
|
||||
) // 不存在
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
31601000: (APIErrorStatus) 系統內部錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler replaceRolePermissions
|
||||
put /roles/:id/permissions (ReplaceRolePermissionsByIDReq)
|
||||
|
||||
@doc "查詢使用者目前指派的角色(含 RoleKey / DisplayName)"
|
||||
/*
|
||||
@respdoc-200 (UserRoleListOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler listUserRoles
|
||||
get /users/:uid/roles (ListUserRolesReq) returns (UserRoleListData)
|
||||
|
||||
@doc "指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)"
|
||||
/*
|
||||
@respdoc-200 (UserRoleOKStatus) // 成功
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
) // 參數錯誤
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) role 不存在
|
||||
) // 不存在
|
||||
@respdoc-409 (
|
||||
31303000: (APIErrorStatus) 角色已指派
|
||||
) // 衝突
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler assignUserRole
|
||||
post /users/:uid/roles (AssignUserRoleByUIDReq) returns (UserRoleData)
|
||||
|
||||
@doc "撤銷使用者的單一角色"
|
||||
/*
|
||||
@respdoc-200 (EmptyOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) 指派不存在
|
||||
) // 不存在
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler revokeUserRole
|
||||
delete /users/:uid/roles/:role_id (RevokeUserRoleByIDReq)
|
||||
|
||||
@doc "列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)"
|
||||
/*
|
||||
@respdoc-200 (RoleMappingListOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler listRoleMappings
|
||||
get /role-mappings (RoleMappingListQuery) returns (RoleMappingListData)
|
||||
|
||||
@doc "Upsert 外部 IdP 群組到內部 role 的映射"
|
||||
/*
|
||||
@respdoc-200 (RoleMappingOKStatus) // 成功
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
) // 參數錯誤
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) 對應 internal role 不存在
|
||||
) // 不存在
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler upsertRoleMapping
|
||||
put /role-mappings (UpsertRoleMappingReq) returns (RoleMappingData)
|
||||
|
||||
@doc "刪除外部 → 內部 role 映射"
|
||||
/*
|
||||
@respdoc-200 (EmptyOKStatus) // 成功
|
||||
@respdoc-400 (
|
||||
10101000: (APIErrorStatus) 參數格式錯誤
|
||||
) // 參數錯誤
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-404 (
|
||||
31301000: (APIErrorStatus) 映射不存在
|
||||
) // 不存在
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
) // 內部錯誤
|
||||
*/
|
||||
@handler deleteRoleMapping
|
||||
delete /role-mappings (DeleteRoleMappingReq)
|
||||
|
||||
@doc "強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)"
|
||||
/*
|
||||
@respdoc-200 (PolicyReloadOKStatus) // 成功
|
||||
@respdoc-401 (
|
||||
31501000: (APIErrorStatus) 未授權
|
||||
) // 未授權
|
||||
@respdoc-500 (
|
||||
31201000: (APIErrorStatus) 資料庫錯誤
|
||||
31601000: (APIErrorStatus) 系統內部錯誤
|
||||
) // 內部錯誤
|
||||
@respdoc-501 (
|
||||
31605000: (APIErrorStatus) Casbin enforcer 未配置
|
||||
) // 未實作
|
||||
*/
|
||||
@handler reloadPolicy
|
||||
post /policy/reload (PolicyReloadReq) returns (PolicyReloadData)
|
||||
}
|
||||
3
go.mod
3
go.mod
|
|
@ -25,6 +25,9 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/ses v1.30.0 // indirect
|
||||
github.com/aws/smithy-go v1.22.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||
github.com/casbin/govaluate v1.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
|
|
|
|||
9
go.sum
9
go.sum
|
|
@ -14,10 +14,16 @@ github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
|||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
|
|
@ -45,6 +51,7 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
|
|||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
|
|
@ -170,6 +177,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
|||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
|
|
@ -198,6 +206,7 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
|||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gateway/internal/logic/permission"
|
||||
"gateway/internal/response"
|
||||
"gateway/internal/svc"
|
||||
"gateway/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
// 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)
|
||||
func AssignUserRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.AssignUserRoleByUIDReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
|
||||
l := permission.NewAssignUserRoleLogic(actorContext(r.Context(), r), svcCtx)
|
||||
data, err := l.AssignUserRole(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package permission
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gateway/internal/logic/permission"
|
||||
"gateway/internal/response"
|
||||
"gateway/internal/svc"
|
||||
"gateway/internal/types"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
)
|
||||
|
||||
// 更新角色(display_name / status;is_system 角色不可改 status)
|
||||
func UpdateRoleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.UpdateRoleByIDReq
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||
return
|
||||
}
|
||||
|
||||
l := permission.NewUpdateRoleLogic(actorContext(r.Context(), r), svcCtx)
|
||||
data, err := l.UpdateRole(&req)
|
||||
response.Write(r.Context(), w, data, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
auth "gateway/internal/handler/auth"
|
||||
member "gateway/internal/handler/member"
|
||||
normal "gateway/internal/handler/normal"
|
||||
permission "gateway/internal/handler/permission"
|
||||
"gateway/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest"
|
||||
|
|
@ -36,6 +37,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/login/social/start",
|
||||
Handler: auth.LoginSocialStartHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 登出(撤銷 access JWT 及配對 refresh JWT)
|
||||
Method: http.MethodPost,
|
||||
Path: "/logout",
|
||||
Handler: auth.LogoutHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
|
||||
Method: http.MethodPost,
|
||||
|
|
@ -66,12 +73,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
Path: "/register/social/start",
|
||||
Handler: auth.RegisterSocialStartHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 登出(撤銷 access JWT 及配對 refresh JWT)
|
||||
Method: http.MethodPost,
|
||||
Path: "/logout",
|
||||
Handler: auth.LogoutHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// ZITADEL id_token 換 CloudEP JWT(企業 SSO)
|
||||
Method: http.MethodPost,
|
||||
|
|
@ -178,4 +179,100 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
|||
rest.WithPrefix("/api/v1"),
|
||||
rest.WithTimeout(3000*time.Millisecond),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{
|
||||
// 取得全局 Permission Catalog(樹狀或扁平;可篩 status/type)
|
||||
Method: http.MethodGet,
|
||||
Path: "/catalog",
|
||||
Handler: permission.GetPermissionCatalogHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 取得當前使用者的 role / permission map(前端渲染選單)
|
||||
Method: http.MethodGet,
|
||||
Path: "/me",
|
||||
Handler: permission.GetMePermissionsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 強制重載 Casbin policy(單租戶或所有租戶;同步 + Pub/Sub broadcast)
|
||||
Method: http.MethodPost,
|
||||
Path: "/policy/reload",
|
||||
Handler: permission.ReloadPolicyHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 列出外部來源 → 內部 role 的映射(zitadel / ldap / scim)
|
||||
Method: http.MethodGet,
|
||||
Path: "/role-mappings",
|
||||
Handler: permission.ListRoleMappingsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// Upsert 外部 IdP 群組到內部 role 的映射
|
||||
Method: http.MethodPut,
|
||||
Path: "/role-mappings",
|
||||
Handler: permission.UpsertRoleMappingHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 刪除外部 → 內部 role 映射
|
||||
Method: http.MethodDelete,
|
||||
Path: "/role-mappings",
|
||||
Handler: permission.DeleteRoleMappingHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 列出租戶內所有角色(含 system role)
|
||||
Method: http.MethodGet,
|
||||
Path: "/roles",
|
||||
Handler: permission.ListRolesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 建立租戶自訂角色(key 不可改、不可使用 system./platform_ 開頭)
|
||||
Method: http.MethodPost,
|
||||
Path: "/roles",
|
||||
Handler: permission.CreateRoleHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 更新角色(display_name / status;is_system 角色不可改 status)
|
||||
Method: http.MethodPatch,
|
||||
Path: "/roles/:id",
|
||||
Handler: permission.UpdateRoleHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 刪除角色(is_system 不可刪;存在 user 指派時拒絕)
|
||||
Method: http.MethodDelete,
|
||||
Path: "/roles/:id",
|
||||
Handler: permission.DeleteRoleHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 讀取角色目前勾選的 permission 集合
|
||||
Method: http.MethodGet,
|
||||
Path: "/roles/:id/permissions",
|
||||
Handler: permission.GetRolePermissionsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 全量取代角色的 permission 勾選(自動補齊父權限;觸發 LoadPolicy + Pub/Sub reload)
|
||||
Method: http.MethodPut,
|
||||
Path: "/roles/:id/permissions",
|
||||
Handler: permission.ReplaceRolePermissionsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 查詢使用者目前指派的角色(含 RoleKey / DisplayName)
|
||||
Method: http.MethodGet,
|
||||
Path: "/users/:uid/roles",
|
||||
Handler: permission.ListUserRolesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 指派角色給使用者(預設 source=manual;source 來源由 SyncFromX 自動標)
|
||||
Method: http.MethodPost,
|
||||
Path: "/users/:uid/roles",
|
||||
Handler: permission.AssignUserRoleHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 撤銷使用者的單一角色
|
||||
Method: http.MethodDelete,
|
||||
Path: "/users/:uid/roles/:role_id",
|
||||
Handler: permission.RevokeUserRoleHandler(serverCtx),
|
||||
},
|
||||
},
|
||||
rest.WithPrefix("/api/v1/permissions"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,4 +140,5 @@ const (
|
|||
Auth Scope = 28
|
||||
Member Scope = 29
|
||||
Notification Scope = 30
|
||||
Permission Scope = 31
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package permission
|
||||
|
||||
import (
|
||||
errs "gateway/internal/library/errors"
|
||||
"gateway/internal/library/errors/code"
|
||||
)
|
||||
|
||||
var errb = errs.For(code.Permission)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,55 @@
|
|||
# Member 模組 — OTP / TOTP
|
||||
# Member 模組
|
||||
|
||||
Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫):
|
||||
Gateway 的會員核心:涵蓋 **Tenant(租戶)**、**Member(會員 profile)**、**Identity(外部身份對映)** 三大實體,以及租戶內 readable UID、業務 email/phone OTP 驗證、TOTP step-up MFA、resend / daily 配額等業務功能。
|
||||
|
||||
| UseCase | 用途 | 典型場景 |
|
||||
|---------|------|----------|
|
||||
| **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 |
|
||||
| **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA |
|
||||
> **架構原則**(`docs/model.md` §6.1):
|
||||
> usecase **不可** 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 **logic 層** 編排。
|
||||
> 本 module 所有 usecase 都是 **atomic primitives**。
|
||||
|
||||
> **架構原則**:usecase **不可**呼叫其他 usecase。
|
||||
> 「產碼 → 寄信/簡訊 → 驗碼 → 更新 profile」這類多步驟流程,由 **logic 層**(或 CLI driver)編排。
|
||||
> 詳見 [`docs/model.md`](../../../docs/model.md) §6.1。
|
||||
---
|
||||
|
||||
## 目錄
|
||||
|
||||
- [核心概念](#核心概念)
|
||||
- [目錄結構](#目錄結構)
|
||||
- [Module 結構與依賴](#module-結構與依賴)
|
||||
- [Atomic UseCase 一覽](#atomic-usecase-一覽)
|
||||
- [資料儲存](#資料儲存)
|
||||
- [生命週期與狀態機](#生命週期與狀態機)
|
||||
- [核心流程時序圖](#核心流程時序圖)
|
||||
- [1. 模組裝配 (NewModuleFromParam)](#1-模組裝配-newmodulefromparam)
|
||||
- [2. Tenant 建立](#2-tenant-建立)
|
||||
- [3. Platform 註冊 (auth + member.Lifecycle)](#3-platform-註冊-auth--memberlifecycle)
|
||||
- [4. Provisioning — OIDC / LDAP / SCIM](#4-provisioning--oidc--ldap--scim)
|
||||
- [5. 業務 Email / Phone OTP 驗證](#5-業務-email--phone-otp-驗證)
|
||||
- [6. TOTP 綁定 / Step-up](#6-totp-綁定--step-up)
|
||||
- [7. UID 生成](#7-uid-生成)
|
||||
- [Redis Key 命名](#redis-key-命名)
|
||||
- [設定](#設定)
|
||||
- [ServiceContext 注入](#servicecontext-注入)
|
||||
- [測試](#測試)
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
| 實體 | 用途 | 主要欄位 | 儲存 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Tenant** | 租戶元資料 | `tenant_id`、`slug`、`uid_prefix`、`status`、`org_id` | Mongo `tenants` |
|
||||
| **Member** | 會員 profile(租戶範圍) | `tenant_id`+`uid`、`zitadel_user_id`、`status`、`origin`、business email/phone、TOTP cipher | Mongo `members` |
|
||||
| **Identity** | 外部 ID → UID 對映表 | `zitadel_user_id`、`external_id`、`uid` | Mongo `identities` |
|
||||
|
||||
**Member 雙鍵**:`(tenant_id, uid)` 為對外的可讀主鍵;`zitadel_user_id` 是 OIDC 來源的對映鍵。
|
||||
**多租戶等級**:每個 Member 必屬於一個 Tenant,UID 用 `{TenantUIDPrefix}-{Sequence}` 格式(例:`ACME-10000003`)。
|
||||
|
||||
### 來源(Origin)
|
||||
|
||||
```
|
||||
platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified)
|
||||
oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC)
|
||||
ldap // Directory Sync(Provisioning.EnsureFromLDAP)
|
||||
scim // SCIM 2.0(Provisioning.EnsureFromSCIM)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -17,321 +57,526 @@ Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫
|
|||
|
||||
```
|
||||
internal/model/member/
|
||||
├── config/ # OTP / TOTP 設定
|
||||
├── domain/ # 介面、enum、errors、redis key
|
||||
│ ├── enum/
|
||||
│ ├── repository/
|
||||
│ └── usecase/
|
||||
├── repository/ # Redis / memory 實作
|
||||
├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library)
|
||||
├── usecase/ # OTPUseCase、TOTPUseCase 實作
|
||||
└── README.md
|
||||
├── config/ # OTP / TOTP / Registration 設定
|
||||
├── domain/ # 介面、enum、entity、errors、redis key helper
|
||||
│ ├── const.go # BSON 欄位、UID 常數
|
||||
│ ├── entity/ # Member、Tenant、Identity Mongo doc
|
||||
│ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind
|
||||
│ ├── errors.go # ErrNotFound、ErrDuplicateMember 等
|
||||
│ ├── redis.go # GetOTPChallengeRedisKey 等 helper
|
||||
│ ├── repository/ # 7 個 repository 介面
|
||||
│ └── usecase/ # 7 個 usecase 介面 + DTO
|
||||
├── repository/ # Mongo / Redis 實作
|
||||
├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL)
|
||||
├── usecase/ # 7 個 usecase 實作 + module factory + mapper
|
||||
└── README.md # 本檔
|
||||
```
|
||||
|
||||
`domain/` 純介面 + 常數,**不依賴外部 lib**(除 `bson.ObjectID`)。
|
||||
`usecase/` 只依賴 `domain/`。
|
||||
`repository/` 依賴 `library/mongo`、`library/redis`。
|
||||
|
||||
---
|
||||
|
||||
## OTP(One-Time Password)
|
||||
## Module 結構與依賴
|
||||
|
||||
### 原理
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Logic["logic 層<br/>(handler 編排)"]
|
||||
|
||||
1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 Redis,TTL 預設 300 秒。
|
||||
2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次;logic 層負責呼叫 `notification.Notifier.Send` 投遞。
|
||||
3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt;成功後 **刪除 challenge**(一次性)。
|
||||
4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5)即鎖定該 challenge。
|
||||
subgraph M["member.Module (atomic usecases)"]
|
||||
direction LR
|
||||
OTP["OTP"]
|
||||
TOTP["TOTP"]
|
||||
Profile["Profile"]
|
||||
Lifecycle["Lifecycle"]
|
||||
Provisioning["Provisioning"]
|
||||
Tenant["Tenant"]
|
||||
VerifyRate["VerifyRate"]
|
||||
end
|
||||
|
||||
subgraph R["domain.Repository (介面)"]
|
||||
MemberRepo["MemberRepository"]
|
||||
TenantRepo["TenantRepository"]
|
||||
IdentityRepo["IdentityRepository"]
|
||||
OTPStore["OTPChallengeStore"]
|
||||
RateStore["VerifyRateStore"]
|
||||
TOTPProf["TOTPProfileRepository"]
|
||||
TOTPEnroll["TOTPEnrollStore"]
|
||||
TOTPReplay["TOTPReplayStore"]
|
||||
UIDGen["UIDGenerator"]
|
||||
end
|
||||
|
||||
subgraph I["repository/ 實作"]
|
||||
Mongo[(MongoDB)]
|
||||
Redis[(Redis)]
|
||||
end
|
||||
|
||||
Logic -->|單呼叫| M
|
||||
OTP --> OTPStore
|
||||
TOTP --> TOTPProf
|
||||
TOTP --> TOTPEnroll
|
||||
TOTP --> TOTPReplay
|
||||
Profile --> MemberRepo
|
||||
Lifecycle --> MemberRepo
|
||||
Lifecycle --> TenantRepo
|
||||
Lifecycle --> UIDGen
|
||||
Provisioning --> MemberRepo
|
||||
Provisioning --> IdentityRepo
|
||||
Provisioning --> TenantRepo
|
||||
Provisioning --> UIDGen
|
||||
Tenant --> TenantRepo
|
||||
VerifyRate --> RateStore
|
||||
|
||||
MemberRepo --- Mongo
|
||||
TenantRepo --- Mongo
|
||||
IdentityRepo --- Mongo
|
||||
TOTPProf --- Mongo
|
||||
OTPStore --- Redis
|
||||
RateStore --- Redis
|
||||
TOTPEnroll --- Redis
|
||||
TOTPReplay --- Redis
|
||||
UIDGen --- Redis
|
||||
```
|
||||
|
||||
**注入規則**:Module factory 依條件啟用 usecase:
|
||||
- `Redis` 必填 → `OTP`、`VerifyRate` 永遠存在。
|
||||
- `MongoConf` 設定 → 啟用 `Profile`、`Lifecycle`、`Tenant`、`Provisioning`。
|
||||
- `TOTP.SecretKEK` 設定 → 啟用 `TOTP`(否則 `mod.TOTP == nil`)。
|
||||
|
||||
---
|
||||
|
||||
## Atomic UseCase 一覽
|
||||
|
||||
| UseCase | 介面方法 | 職責 |
|
||||
| --- | --- | --- |
|
||||
| **TenantUseCase** | `Create` / `ResolveBySlug` | 建立租戶、依 slug 反查 |
|
||||
| **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換(嚴格的 from→to 檢查) |
|
||||
| **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | 讀取 / patch 可變欄位、業務 contact 標記已驗證 |
|
||||
| **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登/同步 upsert(Member + Identity) |
|
||||
| **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 產出/驗證一次性數字碼(bcrypt + Redis) |
|
||||
| **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) |
|
||||
| **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | OTP 重發冷卻 + 每日上限 |
|
||||
|
||||
---
|
||||
|
||||
## 資料儲存
|
||||
|
||||
### MongoDB Collections
|
||||
|
||||
| Collection | Entity | 主要索引 |
|
||||
| --- | --- | --- |
|
||||
| `members` | `Member` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)`(sparse) |
|
||||
| `tenants` | `Tenant` | unique `slug`、unique `uid_prefix` |
|
||||
| `identities` | `Identity` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` |
|
||||
|
||||
索引建立由 `repository.EnsureMongoIndexes` 在啟動時執行(對應 `cmd/mongo-index`)。
|
||||
|
||||
### Redis Keys
|
||||
|
||||
| Key 前綴 | 用途 | TTL |
|
||||
| --- | --- | --- |
|
||||
| `member:otp:challenge:{id}` | OTP challenge 主紀錄(bcrypt hash) | `OTP.TTLSeconds`(預設 300) |
|
||||
| `member:otp:challenge:{id}:attempts` | OTP 錯誤次數計數 | 同 challenge |
|
||||
| `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 lock | `OTP.ResendCooldownSeconds`(預設 60) |
|
||||
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限計數 | 24h |
|
||||
| `member:totp:enroll:{tenant}:{uid}` | 綁定中的 staged secret(AES-GCM cipher) | `TOTP.EnrollTTLSeconds`(預設 600) |
|
||||
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | `TOTP.ReplayTTLSeconds`(預設 90) |
|
||||
| `member:seq:{tenant}` | UID 序號(`INCR`) | 永久 |
|
||||
|
||||
Helper 函式見 `domain/redis.go`,**禁止** 在他處字串拼接 key。
|
||||
|
||||
---
|
||||
|
||||
## 生命週期與狀態機
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> unverified: Lifecycle.CreateUnverified<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 limit(logic 層使用)
|
||||
|
||||
`VerifyRateStore` 提供 resend cooldown 與每日上限,**不在 OTPUseCase 內建**:
|
||||
|
||||
```go
|
||||
// 冷卻(60 秒內不可重發)
|
||||
ok, _ := verifyRate.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenant, uid, "email"), 60*time.Second)
|
||||
|
||||
// 每日上限(預設 10 次)
|
||||
count, _ := verifyRate.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenant, uid, "email"), 24*time.Hour)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TOTP(Time-based OTP)
|
||||
|
||||
### 原理
|
||||
|
||||
遵循 **RFC 6238**,與 Google Authenticator / Authy 相容:
|
||||
|
||||
| 參數 | 預設值 |
|
||||
|------|--------|
|
||||
| 演算法 | HMAC-SHA1 |
|
||||
| 週期 | 30 秒 |
|
||||
| 位數 | 6 |
|
||||
| 時間窗口 | ±1 step(容忍時鐘偏差) |
|
||||
|
||||
**儲存安全**:
|
||||
|
||||
- Secret 以 **AES-256-GCM** 加密(KEK = `Member.TOTP.SecretKEK`)後寫入 profile。
|
||||
- 備援碼以 **bcrypt** 雜湊儲存,明文只在 `ConfirmEnroll` / `RegenerateBackupCodes` 回傳一次。
|
||||
- 綁定前的 staged secret 暫存 Redis(`EnrollTTLSeconds`,預設 600 秒)。
|
||||
- 驗碼成功後以 Redis 記錄 time step,**同一時間窗口內不可重放**。
|
||||
### 2. Tenant 建立
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Logic
|
||||
participant TOTP as TOTPUseCase
|
||||
participant Redis
|
||||
participant Profile
|
||||
autonumber
|
||||
participant CLI as cmd/member-seed
|
||||
participant TenantUC as TenantUseCase
|
||||
participant Repo as TenantRepository
|
||||
participant Mongo
|
||||
|
||||
Note over Logic,Profile: 綁定階段
|
||||
Logic->>TOTP: StartEnroll(tenant, uid, account)
|
||||
TOTP->>Redis: Save encrypted staged secret
|
||||
TOTP-->>Logic: otpauth_url (QR code)
|
||||
User->>User: 掃碼加入 Authenticator
|
||||
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
|
||||
TOTP->>Profile: Save encrypted secret + backup hashes
|
||||
TOTP->>Redis: Delete staged secret
|
||||
TOTP-->>Logic: backup_codes[] (只顯示一次)
|
||||
|
||||
Note over Logic,Profile: 日常使用(step-up)
|
||||
Logic->>TOTP: VerifyCode(tenant, uid, code)
|
||||
TOTP->>Profile: Decrypt secret
|
||||
TOTP->>Redis: MarkUsed(timestep) — 防重放
|
||||
TOTP-->>Logic: ok / err
|
||||
CLI->>TenantUC: Create(req{TenantID, Slug, Name, UIDPrefix})
|
||||
TenantUC->>TenantUC: normalizeUIDPrefix + 長度檢查 (2-4)
|
||||
TenantUC->>Repo: GetByUIDPrefix(prefix)
|
||||
Repo->>Mongo: findOne
|
||||
alt prefix 已存在
|
||||
TenantUC-->>CLI: ErrAlreadyExist("uid_prefix already exists")
|
||||
else 不存在
|
||||
TenantUC->>Repo: Insert(Tenant{Status: active})
|
||||
Repo->>Mongo: insertOne
|
||||
TenantUC-->>CLI: TenantDTO
|
||||
end
|
||||
```
|
||||
|
||||
### API
|
||||
### 3. Platform 註冊 (auth + member.Lifecycle)
|
||||
|
||||
```go
|
||||
// 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code
|
||||
start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com")
|
||||
// start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn
|
||||
> 屬於 `internal/logic/auth/register_logic.go` 的編排;Member module 只負責 atomic 動作。
|
||||
|
||||
// 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼
|
||||
backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913")
|
||||
// backupCodes 只回傳這一次,請引導使用者妥善保存
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Client
|
||||
participant RegLogic as logic/auth.RegisterLogic
|
||||
participant TenantUC as TenantUseCase
|
||||
participant Zitadel as library/zitadel
|
||||
participant Lifecycle as LifecycleUseCase
|
||||
participant OTP as OTPUseCase
|
||||
participant Notifier
|
||||
participant Confirm as logic/auth.RegisterConfirmLogic
|
||||
|
||||
// 3. step-up 驗碼 — TOTP 或備援碼皆可
|
||||
err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP
|
||||
err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪)
|
||||
Client->>RegLogic: POST /auth/register {tenant_slug, email, password}
|
||||
RegLogic->>TenantUC: ResolveBySlug(slug)
|
||||
TenantUC-->>RegLogic: TenantDTO
|
||||
RegLogic->>Zitadel: CreateHumanUser(...)
|
||||
Zitadel-->>RegLogic: zitadel_user_id
|
||||
RegLogic->>Lifecycle: CreateUnverified(req{tenant, email, hash, zitadel_user_id})
|
||||
Lifecycle->>Lifecycle: 取 tenant.UIDPrefix → UIDGenerator.Next
|
||||
Lifecycle->>Lifecycle: members.Insert(status=unverified)
|
||||
Lifecycle-->>RegLogic: MemberDTO(uid)
|
||||
RegLogic->>OTP: Generate(purpose=Register, uid, target=email)
|
||||
OTP-->>RegLogic: challenge_id, plainCode
|
||||
RegLogic->>Notifier: Send(VerifyEmail, code)
|
||||
alt Notifier 失敗
|
||||
RegLogic->>Lifecycle: AbortPending(uid)
|
||||
RegLogic-->>Client: 5xx
|
||||
else 成功
|
||||
RegLogic-->>Client: {challenge_id, expires_in}
|
||||
end
|
||||
|
||||
// 4. 查狀態
|
||||
status, err := totpUC.Status(ctx, "t1", "u1")
|
||||
// status.Enrolled, status.BackupCodesRemaining
|
||||
|
||||
// 5. 停用 / 重產備援碼(logic 層應先要求 step-up)
|
||||
_ = totpUC.Disable(ctx, "t1", "u1")
|
||||
newCodes, err := totpUC.RegenerateBackupCodes(ctx, "t1", "u1")
|
||||
Note over Client,Confirm: 使用者收到信
|
||||
Client->>Confirm: POST /auth/register/confirm {challenge_id, code}
|
||||
Confirm->>OTP: MatchChallenge(challenge_id, tenant, purpose=Register, RequireUID)
|
||||
OTP-->>Confirm: OTPChallengeInfo{uid}
|
||||
Confirm->>OTP: Verify(challenge_id, code, uid, purpose)
|
||||
OTP-->>Confirm: target(email)
|
||||
Confirm->>Lifecycle: Activate(tenant, uid) // unverified → active
|
||||
Confirm-->>Client: JWT (auth 簽發)
|
||||
```
|
||||
|
||||
### VerifyCode 判定順序
|
||||
### 4. Provisioning — OIDC / LDAP / SCIM
|
||||
|
||||
1. 長度 = 6 → 當 TOTP 驗(含 ±window)
|
||||
2. 通過 → Redis 記錄 time step;已用過則回 `ErrTOTPCodeReplay`
|
||||
3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
|
||||
4. 皆失敗 → `ErrTOTPInvalidCode`
|
||||
外部身份首次登入時透過 `EnsureFromOIDC` upsert,**冪等**(既存即回傳)。
|
||||
|
||||
---
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Logic as logic/auth.LoginSocialCallback
|
||||
participant Prov as ProvisioningUseCase
|
||||
participant MR as MemberRepository
|
||||
participant IR as IdentityRepository
|
||||
participant TR as TenantRepository
|
||||
participant UID as UIDGenerator
|
||||
participant Redis
|
||||
participant Mongo
|
||||
|
||||
## 設定
|
||||
|
||||
`etc/gateway.dev.yaml` → `Member` 區塊:
|
||||
|
||||
```yaml
|
||||
Member:
|
||||
OTP:
|
||||
Length: 6 # 驗證碼位數
|
||||
TTLSeconds: 300 # challenge 存活時間
|
||||
MaxAttempts: 5 # 單 challenge 最大錯誤次數
|
||||
ResendCooldownSeconds: 60 # 重發冷卻(logic 層用 VerifyRateStore)
|
||||
DailyVerifyLimit: 10 # 每日上限(logic 層用 VerifyRateStore)
|
||||
TOTP:
|
||||
Issuer: CloudEP
|
||||
Algorithm: SHA1
|
||||
Digits: 6
|
||||
PeriodSeconds: 30
|
||||
Window: 1 # ±1 time step
|
||||
BackupCodeCount: 10
|
||||
BackupCodeLength: 12
|
||||
EnrollTTLSeconds: 600 # 綁定 staged secret TTL
|
||||
ReplayTTLSeconds: 90 # 重放保護 TTL
|
||||
SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空則不啟用 TOTP
|
||||
Logic->>Prov: EnsureFromOIDC(tenant, zitadel_sub, email, ...)
|
||||
Prov->>MR: GetByZitadelUserID(tenant, sub)
|
||||
MR->>Mongo: find
|
||||
alt 已存在
|
||||
MR-->>Prov: Member
|
||||
Prov-->>Logic: MemberDTO (origin=oidc, status=active)
|
||||
else ErrNotFound
|
||||
Prov->>TR: GetByTenantID(tenant)
|
||||
TR-->>Prov: Tenant{UIDPrefix}
|
||||
Prov->>UID: Next(tenant, prefix)
|
||||
UID->>Redis: INCR member:seq:{tenant}
|
||||
UID-->>Prov: "ACME-10000003"
|
||||
Prov->>MR: Insert(Member{status=active, origin=oidc, zitadel_user_id})
|
||||
MR->>Mongo: insertOne
|
||||
alt duplicate(競態)
|
||||
MR-->>Prov: ErrDuplicateMember
|
||||
Prov->>MR: GetByZitadelUserID // 再讀一次回傳
|
||||
end
|
||||
Prov->>IR: Insert(Identity{zitadel_user_id, uid})
|
||||
IR->>Mongo: insertOne(忽略 dup)
|
||||
Prov-->>Logic: MemberDTO
|
||||
end
|
||||
```
|
||||
|
||||
`SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入(production 建議走 KMS / secret manager)。
|
||||
LDAP / SCIM 同樣模式,額外查 `IdentityRepository.GetByExternalID` 處理沒有 zitadel_sub 的情境。
|
||||
|
||||
---
|
||||
### 5. 業務 Email / Phone OTP 驗證
|
||||
|
||||
## 裝配與注入
|
||||
由 `internal/logic/member/verify_helper.go` 編排(`startVerification` + `confirmVerification`),展示 logic 層如何把多個 atomic usecase 串起來。
|
||||
|
||||
### Module factory
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Client
|
||||
participant Logic as logic/member.startVerification
|
||||
participant Rate as VerifyRateUseCase
|
||||
participant OTP as OTPUseCase
|
||||
participant Notif as Notifier
|
||||
participant Profile as ProfileUseCase
|
||||
participant Redis
|
||||
|
||||
```go
|
||||
mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||
Redis: rds,
|
||||
Config: c.Member,
|
||||
})
|
||||
// mod.OTP — 永遠有值(需 Redis)
|
||||
// mod.TOTP — SecretKEK 有設定時才有值,否則 nil
|
||||
// mod.VerifyRate — resend / daily cap
|
||||
// mod.Profile — 預設 memory,P4 換 Mongo
|
||||
Client->>Logic: POST /me/verifications/email/start {target}
|
||||
Logic->>Rate: AssertResendAllowed(rateKey, cooldown=60s)
|
||||
Rate->>Redis: SETNX member:verify:rate:{t}:{u}:business_email
|
||||
alt cooldown 中
|
||||
Rate-->>Logic: ErrTooManyRequest
|
||||
Logic-->>Client: 429
|
||||
end
|
||||
Logic->>Rate: AssertDailyAllowed(dailyKey, 24h, limit=10)
|
||||
Rate->>Redis: INCR member:verify:daily:{t}:{u}:business_email
|
||||
Logic->>OTP: Generate(uid, purpose=BusinessEmail, target=email)
|
||||
OTP->>Redis: SET member:otp:challenge:{id} (bcrypt hash, TTL=300s)
|
||||
OTP-->>Logic: challenge_id, plainCode
|
||||
Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in})
|
||||
alt Notifier 失敗
|
||||
Logic->>OTP: Invalidate(challenge_id)
|
||||
Logic-->>Client: 5xx
|
||||
else 成功
|
||||
Logic-->>Client: {challenge_id, expires_in}
|
||||
end
|
||||
|
||||
Note over Client,Profile: 使用者收到信
|
||||
Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code}
|
||||
Logic->>OTP: Verify(challenge_id, code, uid, purpose=BusinessEmail)
|
||||
OTP->>Redis: GET + bcrypt compare
|
||||
alt 失敗
|
||||
OTP->>Redis: INCR attempts
|
||||
alt attempts >= 5
|
||||
OTP-->>Logic: ErrChallengeLocked
|
||||
else
|
||||
OTP-->>Logic: ErrInvalidOTP
|
||||
end
|
||||
else 成功
|
||||
OTP->>Redis: DEL challenge
|
||||
OTP-->>Logic: target(email)
|
||||
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
|
||||
Profile-->>Logic: nil
|
||||
Logic-->>Client: 204
|
||||
end
|
||||
```
|
||||
|
||||
### ServiceContext
|
||||
**關鍵設計**:`Verify` 成功後 challenge **立刻刪除**(一次性);`Generate` 一定要先過 `VerifyRate` 兩道閘門。
|
||||
|
||||
Gateway 啟動時(Redis 就緒)自動注入:
|
||||
### 6. TOTP 綁定 / Step-up
|
||||
|
||||
```go
|
||||
svc.MemberOTP // domusecase.OTPUseCase
|
||||
svc.MemberTOTP // domusecase.TOTPUseCase(可能 nil)
|
||||
svc.MemberVerifyRate // VerifyRateStore
|
||||
svc.MemberProfile // ProfileRepository
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Client
|
||||
participant Logic
|
||||
participant TOTP as TOTPUseCase
|
||||
participant Profile as TOTPProfileRepository
|
||||
participant Enroll as TOTPEnrollStore
|
||||
participant Replay as TOTPReplayStore
|
||||
participant Cipher as crypto.Cipher (AES-GCM)
|
||||
|
||||
Note over Client,Cipher: A. 綁定階段
|
||||
Client->>Logic: POST /me/totp/enroll
|
||||
Logic->>TOTP: StartEnroll(tenant, uid, account)
|
||||
TOTP->>Profile: Get → 必須未 enrolled
|
||||
TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte)
|
||||
TOTP->>Cipher: Encrypt(secret) → cipherBlob
|
||||
TOTP->>Enroll: Save(cipherBlob, TTL=600s)
|
||||
TOTP-->>Logic: {otpauth_url, digits=6, period=30}
|
||||
Logic-->>Client: QR code 資料
|
||||
|
||||
Client->>Client: 掃 QR 加入 Authenticator
|
||||
Client->>Logic: POST /me/totp/enroll/confirm {code}
|
||||
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
|
||||
TOTP->>Enroll: Get → cipherBlob
|
||||
TOTP->>Cipher: Decrypt → secret
|
||||
TOTP->>TOTP: totp.Verify(secret, code, ±window)
|
||||
alt 驗碼失敗
|
||||
TOTP-->>Logic: ErrTOTPInvalidCode
|
||||
else 成功
|
||||
TOTP->>TOTP: 產生 N 個 backup codes + bcrypt hashes
|
||||
TOTP->>Profile: Save({Enrolled, SecretCipher, BackupCodesHash})
|
||||
TOTP->>Enroll: Delete (清掉 staged)
|
||||
TOTP-->>Logic: plainCodes[](僅此一次回傳)
|
||||
end
|
||||
|
||||
Note over Client,Replay: B. 日常 step-up
|
||||
Client->>Logic: 任意敏感操作攜 6 碼
|
||||
Logic->>TOTP: VerifyCode(tenant, uid, code)
|
||||
TOTP->>Profile: Get → 必須 enrolled
|
||||
TOTP->>Cipher: Decrypt(SecretCipher)
|
||||
alt code 長度 = 6
|
||||
TOTP->>TOTP: totp.Verify(±window) → step
|
||||
alt OK
|
||||
TOTP->>Replay: MarkUsed(timestep, TTL=90s) → fresh?
|
||||
alt 已用過
|
||||
TOTP-->>Logic: ErrTOTPCodeReplay
|
||||
else 未用過
|
||||
TOTP-->>Logic: nil
|
||||
end
|
||||
else 失敗
|
||||
TOTP->>TOTP: fall through to backup code
|
||||
end
|
||||
end
|
||||
alt 嘗試備援碼
|
||||
loop 每組 hash
|
||||
TOTP->>TOTP: bcrypt.CompareHashAndPassword
|
||||
end
|
||||
alt 命中
|
||||
TOTP->>Profile: ConsumeBackupCode(hash) (atomic)
|
||||
TOTP-->>Logic: nil
|
||||
else 全失敗
|
||||
TOTP-->>Logic: ErrTOTPInvalidCode
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
### 7. UID 生成
|
||||
|
||||
## Logic 層編排範例
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Caller as Lifecycle / Provisioning
|
||||
participant Gen as UIDGenerator
|
||||
participant Redis
|
||||
|
||||
以下示範 **verify business email** 完整流程(logic 層職責,尚未有 HTTP handler):
|
||||
|
||||
```go
|
||||
// ── 發起驗證 ──
|
||||
dto, code, err := svc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
|
||||
TenantID: tenant, UID: uid,
|
||||
Purpose: enum.OTPPurposeBusinessEmail,
|
||||
Target: email,
|
||||
})
|
||||
if err != nil { return err }
|
||||
|
||||
_, err = svc.Notifier.Send(ctx, ¬if.SendRequest{
|
||||
TenantID: tenant, UID: uid,
|
||||
Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail,
|
||||
Target: email, Locale: locale,
|
||||
Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn},
|
||||
IdempotencyKey: dto.ChallengeID,
|
||||
})
|
||||
if err != nil {
|
||||
_ = svc.MemberOTP.Invalidate(ctx, dto.ChallengeID) // 寄送失敗回滾
|
||||
return err
|
||||
}
|
||||
return dto // 回傳 challenge_id 給前端
|
||||
|
||||
// ── 確認驗證 ──
|
||||
target, err := svc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{
|
||||
TenantID: tenant, UID: uid,
|
||||
ChallengeID: req.ChallengeID, Code: req.Code,
|
||||
Purpose: enum.OTPPurposeBusinessEmail,
|
||||
})
|
||||
if err != nil { return err }
|
||||
|
||||
return svc.MemberProfile.SetBusinessEmailVerified(ctx, tenant, uid, target)
|
||||
Caller->>Gen: Next(tenant, uidPrefix)
|
||||
Gen->>Redis: INCR member:seq:{tenant}
|
||||
Redis-->>Gen: seq
|
||||
alt seq == 1 (首次)
|
||||
Note right of Gen: 一次補上起始值<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")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 測試
|
||||
|
||||
### 本機 API(P4)
|
||||
|
||||
> JWT / Casbin 尚未接入;dev 模式用 Header 帶身份:
|
||||
> `X-Tenant-ID`、`X-UID`
|
||||
|
||||
```bash
|
||||
make deps-up
|
||||
make mongo-index
|
||||
make member-seed # 建立 dev tenant + member,輸出 headers
|
||||
make run-local # 或 make run
|
||||
|
||||
# 範例
|
||||
curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
|
||||
http://127.0.0.1:8888/api/v1/members/me | jq
|
||||
|
||||
# 業務 email 驗證(logic 層:OTP.Generate → Notifier.Send)
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
|
||||
-d '{"target":"you@example.com"}' \
|
||||
http://127.0.0.1:8888/api/v1/members/me/verifications/email/start | jq
|
||||
```
|
||||
|
||||
完整 API 見 `generate/api/member.api`(§7.2 對照表)。
|
||||
|
||||
### 單元測試
|
||||
|
||||
```bash
|
||||
|
|
@ -339,45 +584,48 @@ go test ./internal/model/member/... -v
|
|||
make check
|
||||
```
|
||||
|
||||
### 互動式 TOTP(Google Authenticator)
|
||||
| 檔案 | 覆蓋 |
|
||||
| --- | --- |
|
||||
| `usecase/otp_usecase_test.go` | Generate/Verify、UID/purpose mismatch、attempts lock |
|
||||
| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
|
||||
| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
|
||||
|
||||
本機需 Redis,並在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
|
||||
### 本機 API(P4)
|
||||
|
||||
```bash
|
||||
make deps-up # docker compose: mongo + redis
|
||||
make mongo-index # 建索引
|
||||
make member-seed # 建 dev tenant + 一筆 member,輸出 X-Tenant-ID/X-UID headers
|
||||
make run-local # 啟動 gateway
|
||||
|
||||
# Profile
|
||||
curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
|
||||
http://127.0.0.1:8888/api/v1/members/me | jq
|
||||
|
||||
# 業務 email 驗證(start → confirm)
|
||||
curl -s -X POST -H "Content-Type: application/json" \
|
||||
-H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
|
||||
-d '{"target":"you@example.com"}' \
|
||||
http://127.0.0.1:8888/api/v1/members/me/verifications/email/start | jq
|
||||
```
|
||||
|
||||
完整 API 見 `generate/api/member.api`。
|
||||
|
||||
### 互動式 TOTP(Google Authenticator)
|
||||
|
||||
```bash
|
||||
make deps-up
|
||||
make totp-test
|
||||
```
|
||||
|
||||
流程(單一 process,預設 `-step flow`):
|
||||
|
||||
1. 終端機印出 **QR code** 與 **Secret key**
|
||||
2. 手機 Google Authenticator → 掃描 QR(或手動輸入 Secret)
|
||||
3. 輸入 Authenticator 上的 6 碼 → **ConfirmEnroll**(綁定完成,顯示備援碼)
|
||||
4. 等 code 刷新後再輸入新 6 碼 → **VerifyCode**(step-up 驗證)
|
||||
5. 自動測試重放保護(同一碼再驗應失敗)
|
||||
|
||||
進階:
|
||||
|
||||
```bash
|
||||
make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放
|
||||
make totp-test STEP=status
|
||||
make totp-test STEP=disable
|
||||
make totp-test STEP=verify CODE=482913
|
||||
```
|
||||
|
||||
| 檔案 | 覆蓋 |
|
||||
|------|------|
|
||||
| `usecase/otp_usecase_test.go` | Generate/Verify、UID mismatch、max attempts lock |
|
||||
| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
|
||||
| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
|
||||
| `library/crypto/aesgcm_test.go` | TOTP secret 加解密 |
|
||||
需在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。
|
||||
|
||||
---
|
||||
|
||||
## 尚未實作
|
||||
## 設計參考
|
||||
|
||||
- HTTP API / goctl handler(verify-email、verify-phone、totp enroll 等)
|
||||
- Logic 層 confirm 流程(Verify + Profile flip + rate limit)
|
||||
- `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory)
|
||||
- Step-up token 簽發(auth 模組)
|
||||
|
||||
設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。
|
||||
- 詳細領域模型 / 多租戶設計 / B2B Permission 對接:`docs/identity-member-design.md`
|
||||
- 模組分層公約(usecase 不可呼叫 usecase):`docs/model.md` §6.1
|
||||
- 統一錯誤格式(`errb.*`):`internal/library/errors/README.md`
|
||||
|
|
|
|||
|
|
@ -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 一旦建立 **不可改**;外部 IdP(ZITADEL / LDAP / SCIM)以 Key 作對應。
|
||||
- 多 pod 同步:**Redis Pub/Sub 即時通知 + 5min cron 兜底**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心概念
|
||||
|
||||
| 概念 | 簡述 | 關鍵欄位 |
|
||||
|------|------|----------|
|
||||
| **Permission** | 平台級權限節點(樹狀,dot notation) | `name` 唯一、`http_methods` + `http_path` 命中 Casbin policy |
|
||||
| **Role** | 租戶內的角色 | `tenant_id + key` unique;`is_system=true` 不可刪 |
|
||||
| **RolePermission** | Role 勾選了哪些 Permission | 自動補齊 parent permission ID |
|
||||
| **UserRole** | 使用者被指派的角色(多角色) | `source` 區分 manual / zitadel / ldap / scim |
|
||||
| **RoleMapping** | 外部 group/role → 內部 Role.Key | SyncFromX 用來翻譯 IdP claims |
|
||||
| **Casbin Policy** | 物化後的授權規則(Redis Set) | `(tenant, role, path, methods, name)` |
|
||||
|
||||
### 1.1 Permission Tree 範例
|
||||
|
||||
```
|
||||
member.info.management ← 分類(無 HTTP)
|
||||
├── member.basic.info ← 二級分類
|
||||
│ ├── member.info.select GET /api/v1/members/me
|
||||
│ └── member.info.update PATCH /api/v1/members/me
|
||||
├── member.admin.list GET /api/v1/members
|
||||
└── member.admin.read GET /api/v1/members/:uid
|
||||
|
||||
permission.role.management ← 分類
|
||||
├── permission.role.read GET /api/v1/permissions/roles
|
||||
├── permission.role.write POST/PUT/DELETE /api/v1/permissions/roles*
|
||||
└── permission.assign.write POST/DELETE /api/v1/permissions/users/*/roles*
|
||||
```
|
||||
|
||||
> 分類節點(無 `http_path`)**不會**寫入 Casbin policy;它們只是 UI 樹狀渲染與 parent closure 用。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目錄結構
|
||||
|
||||
```
|
||||
internal/model/permission/
|
||||
├── README.md # 本文件
|
||||
├── config/
|
||||
│ └── config.go # CasbinConfig / CacheConfig / ReloadConfig
|
||||
├── domain/
|
||||
│ ├── const.go # BSON 欄位 / Casbin / Role.Key 規則
|
||||
│ ├── errors.go # 模組共用 sentinel errors
|
||||
│ ├── redis.go # Redis key helpers (casbin / user_roles / role_perms)
|
||||
│ ├── entity/
|
||||
│ │ ├── permission.go # Permission catalog node
|
||||
│ │ ├── role.go
|
||||
│ │ ├── role_permission.go
|
||||
│ │ ├── user_role.go
|
||||
│ │ └── role_mapping.go
|
||||
│ ├── enum/
|
||||
│ │ ├── status.go # open / close + Permissions map
|
||||
│ │ ├── permission_type.go # backend_user / frontend_user
|
||||
│ │ └── role_source.go # manual / zitadel / ldap / scim
|
||||
│ ├── repository/ # 介面(+ Casbin adapter port)
|
||||
│ │ ├── permission.go
|
||||
│ │ ├── role.go
|
||||
│ │ ├── role_permission.go
|
||||
│ │ ├── user_role.go
|
||||
│ │ ├── role_mapping.go
|
||||
│ │ └── casbin_adapter.go
|
||||
│ └── usecase/ # 介面 + DTO
|
||||
│ ├── permission.go
|
||||
│ ├── role.go
|
||||
│ ├── role_permission.go
|
||||
│ ├── user_role.go
|
||||
│ ├── role_mapping.go
|
||||
│ ├── rbac.go
|
||||
│ └── authorization_query.go
|
||||
├── repository/ # Mongo + Redis 實作
|
||||
│ ├── index.go # EnsureMongoIndexes + bsonOpSet
|
||||
│ ├── permission_mongo.go
|
||||
│ ├── role_mongo.go
|
||||
│ ├── role_permission_mongo.go
|
||||
│ ├── user_role_mongo.go
|
||||
│ ├── role_mapping_mongo.go
|
||||
│ └── casbin_redis.go # tenant-scoped policy Redis Set
|
||||
├── usecase/ # atomic primitives (7)
|
||||
│ ├── module.go # NewModuleFromParam
|
||||
│ ├── errors.go # wrapRepoErr → errs.For(code.Permission)
|
||||
│ ├── permission_tree.go # buildTree / filterOpenNodes / parent closure
|
||||
│ ├── permission_usecase.go
|
||||
│ ├── role_usecase.go
|
||||
│ ├── role_permission_usecase.go
|
||||
│ ├── user_role_usecase.go
|
||||
│ ├── role_mapping_usecase.go
|
||||
│ ├── authorization_query_usecase.go
|
||||
│ └── rbac_usecase.go # Casbin enforcer + LoadPolicy + Pub/Sub reload
|
||||
└── seed/
|
||||
├── catalog.go # embed + Apply + DefaultSystemRoles
|
||||
└── catalog.json # 平台 seed 資料
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 模組依賴
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Logic[logic/permission] --> SVC[svc.ServiceContext]
|
||||
SVC --> AuthQ[AuthorizationQueryUseCase]
|
||||
SVC --> Perm[PermissionUseCase]
|
||||
SVC --> Role[RoleUseCase]
|
||||
SVC --> RolePerm[RolePermissionUseCase]
|
||||
SVC --> UserRole[UserRoleUseCase]
|
||||
SVC --> Mapping[RoleMappingUseCase]
|
||||
SVC --> RBAC[RBACUseCase]
|
||||
|
||||
AuthQ --> RoleR[(roles)]
|
||||
AuthQ --> PermR[(permissions)]
|
||||
AuthQ --> RPR[(role_permissions)]
|
||||
AuthQ --> URR[(user_roles)]
|
||||
|
||||
Perm --> PermR
|
||||
Role --> RoleR
|
||||
Role --> URR
|
||||
RolePerm --> RPR
|
||||
RolePerm --> RoleR
|
||||
RolePerm --> PermR
|
||||
UserRole --> URR
|
||||
UserRole --> RoleR
|
||||
Mapping --> RMR[(role_mappings)]
|
||||
Mapping --> RoleR
|
||||
|
||||
RBAC --> RoleR
|
||||
RBAC --> PermR
|
||||
RBAC --> RPR
|
||||
RBAC --> URR
|
||||
RBAC --> Adapter[Casbin Redis Adapter]
|
||||
Adapter --> Redis[(Redis)]
|
||||
RBAC --> Pub[Redis Pub/Sub]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UseCase 介面(7 個)
|
||||
|
||||
| UseCase | 主要方法 | 注入 |
|
||||
|---------|----------|------|
|
||||
| `PermissionUseCase` | `GetCatalogTree` / `List` / `UpsertCatalog` / `UpdateStatus` | PermissionRepository |
|
||||
| `RoleUseCase` | `Create` / `Get` / `List` / `Update` / `Delete` | Role + RolePermission + UserRole |
|
||||
| `RolePermissionUseCase` | `List` / `Replace` | Role + Permission + RolePermission + Reloader |
|
||||
| `UserRoleUseCase` | `Assign` / `Revoke` / `List` / `ReplaceForSource` | Role + UserRole + Reloader |
|
||||
| `RoleMappingUseCase` | `Upsert` / `Delete` / `GetByExternal` / `List` | Role + RoleMapping |
|
||||
| `AuthorizationQueryUseCase` | `Me` | Role + Permission + RolePermission + UserRole |
|
||||
| `RBACUseCase` | `Check` / `LoadPolicy` / `LoadAllPolicies` / `BroadcastReload` / `Start/StopReloadSubscriber` | All repos + Redis |
|
||||
|
||||
---
|
||||
|
||||
## 5. 資料儲存
|
||||
|
||||
### 5.1 MongoDB
|
||||
|
||||
| Collection | 索引 | 用途 |
|
||||
|------------|------|------|
|
||||
| `permissions` | `name`(uniq) / `parent` / `status` / `type` | 平台 Permission Catalog(樹狀) |
|
||||
| `roles` | `(tenant_id, key)`(uniq) / `(tenant_id, is_system)` | 租戶角色 |
|
||||
| `role_permissions` | `(tenant_id, role_id, permission_id)`(uniq) / `(tenant_id, permission_id)` | Role↔Permission 多對多 |
|
||||
| `user_roles` | `(tenant_id, uid, role_id)`(uniq) / `(tenant_id, role_id)` / `(tenant_id, uid, source)` | User↔Role 多對多 |
|
||||
| `role_mappings` | `(tenant_id, external_source, external_key)`(uniq) / `(tenant_id, internal_role_id)` | 外部 group → 內部 Role |
|
||||
|
||||
啟動時呼叫 `permrepo.EnsureMongoIndexes(ctx, &c.Mongo)`(已掛在 `cmd/mongo-index`)。
|
||||
|
||||
### 5.2 Redis Key
|
||||
|
||||
| Key | 內容 | TTL | 由誰寫 |
|
||||
|-----|------|-----|--------|
|
||||
| `permission:casbin:rules:{tenant_id}` | Set of JSON-encoded `[]string` rules | 永久 | `RBACUseCase.LoadPolicy` / `BroadcastReload` |
|
||||
| `perm:user_roles:{tenant_id}:{uid}` | List of role keys(讀取快取,預留) | `Cache.UserRolesTTLSeconds` | 預留 |
|
||||
| `perm:role_perms:{tenant_id}:{role_id}` | List of permission names(預留) | `Cache.RolePermsTTLSeconds` | 預留 |
|
||||
| `permission:tree:open` | 序列化的全局 open tree(預留) | `Cache.CatalogTTLSeconds` | 預留 |
|
||||
| (channel) `casbin:reload` | Pub/Sub payload `{tenant_id, ts}` | — | `RBACUseCase.BroadcastReload` |
|
||||
|
||||
> Redis Set + JSON 編碼是為了讓 SaveAll 用 pipelined `DEL + SADD` 一致性更新;Pub/Sub 走獨立 go-redis client(go-zero 沒有 Subscribe),詳見 `internal/library/redis/pubsub.go`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心流程時序圖
|
||||
|
||||
### 6.1 NewModuleFromParam — 模組組裝
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Boot as svc.NewServiceContext
|
||||
participant Mod as permission.NewModuleFromParam
|
||||
participant Cfg as config.Defaults()
|
||||
participant Repo as Mongo Repos (5)
|
||||
participant Casbin as RBACUseCase
|
||||
participant Redis as PolicyAdapter
|
||||
|
||||
Boot->>Mod: FactoryParam{MongoConf, Redis, Config}
|
||||
Mod->>Cfg: cfg = Config.Defaults()
|
||||
Mod->>Repo: NewPermission/Role/.../RoleMapping Repository
|
||||
Note over Mod: 若已注入 repo(測試)跳過
|
||||
alt cfg.Casbin.Enabled && Redis 有
|
||||
Mod->>Casbin: NewRBACUseCase(repos+Redis)
|
||||
Casbin-->>Mod: rbacUC
|
||||
Mod->>Redis: RedisAdapterFactory = NewCasbinRedisAdapter
|
||||
Mod->>Mod: reloader = rbacUC.BroadcastReload
|
||||
else 無 Redis 或 Disabled
|
||||
Mod->>Mod: rbacUC = nil(Check 永遠 deny)
|
||||
end
|
||||
Mod->>Mod: New {Permission, Role, RolePermission, UserRole, RoleMapping, AuthorizationQuery}
|
||||
Mod-->>Boot: *Module(7 usecases + 5 repos)
|
||||
```
|
||||
|
||||
### 6.2 Permission Catalog Seed
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant CLI as cmd/permission-seed
|
||||
participant Cfg as config.Mongo
|
||||
participant Idx as permrepo.EnsureMongoIndexes
|
||||
participant Seed as seed.Apply
|
||||
participant Cat as Permissions
|
||||
participant Roles as Roles + RolePermissions
|
||||
|
||||
CLI->>Cfg: load -f etc/gateway.dev.yaml
|
||||
CLI->>Idx: 建立 5 collections 索引
|
||||
CLI->>Seed: Apply(perms, roles, rolePerms, opts)
|
||||
alt SkipCatalog == false
|
||||
Seed->>Cat: 第一輪 UpsertByName(不含 parent)
|
||||
Seed->>Cat: GetAll → 建 name→ID index
|
||||
Seed->>Cat: 第二輪 UpsertByName(補 parent ID)
|
||||
end
|
||||
loop opts.TenantIDs
|
||||
Seed->>Roles: GetByKey or Insert is_system role
|
||||
Seed->>Roles: SetForRole(roleID, [permIDs]) ← 全量取代
|
||||
end
|
||||
Seed-->>CLI: Report{ catalog, roles, role_perms }
|
||||
CLI-->>CLI: stdout summary
|
||||
```
|
||||
|
||||
> 預設 5 個 system role:`tenant_owner` / `tenant_admin` / `member_manager` / `member` / `viewer`,定義於 `seed/catalog.go::DefaultSystemRoles`。
|
||||
|
||||
### 6.3 Role 建立 / 更新 / 刪除
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as POST/PATCH/DELETE /permissions/roles
|
||||
participant Logic as logic.permission.*
|
||||
participant UC as RoleUseCase
|
||||
participant Repo as RoleRepository
|
||||
participant URR as UserRoleRepository
|
||||
|
||||
API->>Logic: req + actor (tenant_id, uid)
|
||||
Logic->>UC: Create / Update / Delete
|
||||
alt Create
|
||||
UC->>UC: validateRoleKey(^[a-z][a-z0-9._-]+$、不可 system./platform_)
|
||||
UC->>Repo: Insert(role) ← unique (tenant_id, key)
|
||||
else Update
|
||||
UC->>Repo: GetByID
|
||||
UC->>UC: 阻擋 is_system 改 status
|
||||
UC->>Repo: FindOneAndUpdate
|
||||
else Delete
|
||||
UC->>Repo: GetByID
|
||||
UC->>UC: 阻擋 is_system
|
||||
UC->>URR: ListByRole(仍有指派 → 拒絕)
|
||||
UC->>Repo: DeleteByRole(role_perms)
|
||||
UC->>Repo: Delete(role)
|
||||
end
|
||||
UC-->>Logic: role
|
||||
Logic-->>API: types.RoleData
|
||||
```
|
||||
|
||||
### 6.4 RolePermission 全量取代(PUT /roles/:id/permissions)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as PUT /permissions/roles/:id/permissions
|
||||
participant Logic as logic.replaceRolePermissions
|
||||
participant UC as RolePermissionUseCase
|
||||
participant Roles as RoleRepository
|
||||
participant Perms as PermissionRepository
|
||||
participant RP as RolePermissionRepository
|
||||
participant RBAC as RBACUseCase
|
||||
|
||||
API->>Logic: req{ID, PermissionIDs}
|
||||
Logic->>UC: Replace(tenantID, roleID, ids)
|
||||
UC->>Roles: GetByID(驗證 tenant 一致)
|
||||
UC->>Perms: GetAll(拿到 catalog 全表)
|
||||
UC->>UC: 檢查 ids ⊆ catalog
|
||||
UC->>UC: getFullParentPermissionIDs(ids, all)
|
||||
UC->>RP: SetForRole(tenantID, roleID, closure)
|
||||
Note over RP: DeleteMany + InsertMany 原子化
|
||||
UC->>RBAC: BroadcastReload(tenantID)
|
||||
RBAC-->>UC: ok(fire-and-forget)
|
||||
UC-->>Logic: nil
|
||||
Logic-->>API: 200 OK
|
||||
```
|
||||
|
||||
### 6.5 UserRole 指派 / 撤銷
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant API as POST /permissions/users/:uid/roles
|
||||
participant UC as UserRoleUseCase
|
||||
participant Roles as RoleRepository
|
||||
participant URR as UserRoleRepository
|
||||
participant RBAC as RBACUseCase
|
||||
|
||||
API->>UC: Assign{tenant, uid, role_id, source=manual}
|
||||
UC->>Roles: GetByID (tenant scope check)
|
||||
UC->>URR: Insert(unique tenant+uid+role)
|
||||
UC->>RBAC: BroadcastReload(tenant)
|
||||
UC-->>API: UserRole
|
||||
```
|
||||
|
||||
### 6.6 SyncFromX 流程(外部 IdP 來源同步)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Sync as auth/provisioning
|
||||
participant UC as UserRoleUseCase
|
||||
participant Map as RoleMappingUseCase
|
||||
participant Roles as RoleRepository
|
||||
participant URR as UserRoleRepository
|
||||
participant RBAC as RBACUseCase
|
||||
|
||||
Sync->>Map: GetByExternal(tenant, source=zitadel, externalKey)
|
||||
Map-->>Sync: RoleMapping(internal_role_key)
|
||||
Note over Sync: 收齊 IdP 端所有 roles → keys
|
||||
Sync->>UC: ReplaceForSource(tenant, uid, source=zitadel, [roleKeys])
|
||||
UC->>UC: 阻擋 source==manual(防誤洗)
|
||||
loop key in roleKeys
|
||||
UC->>Roles: GetByKey (skip 不存在的)
|
||||
end
|
||||
UC->>URR: ReplaceForSource(tenant, uid, source, [roleIDs])
|
||||
Note over URR: DeleteMany source=zitadel + BulkInsert<br/>※ source=manual 紀錄不動
|
||||
UC->>RBAC: BroadcastReload(tenant)
|
||||
```
|
||||
|
||||
### 6.7 LoadPolicy(Casbin 規則載入)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Trigger as Replace / Reload / Boot
|
||||
participant RBAC as RBACUseCase
|
||||
participant Roles as RoleRepository
|
||||
participant RP as RolePermissionRepository
|
||||
participant Perms as PermissionRepository
|
||||
participant Enf as casbin.SyncedEnforcer
|
||||
participant Adp as Redis Adapter
|
||||
|
||||
Trigger->>RBAC: LoadPolicy(tenantID)
|
||||
RBAC->>Roles: ListByTenant
|
||||
RBAC->>RP: ListByRoles(roleIDs)
|
||||
RBAC->>Perms: GetByIDs(unique perm ids)
|
||||
RBAC->>RBAC: 過濾 IsLeaf() && Status=open
|
||||
RBAC->>RBAC: rules = [tenant, role.key, http_path, http_methods, perm.name]
|
||||
RBAC->>Enf: ClearPolicy + AddPolicies
|
||||
RBAC->>Adp: SaveAll(tenant, rules) ← Redis pipelined DEL+SADD
|
||||
RBAC-->>Trigger: nil
|
||||
```
|
||||
|
||||
### 6.8 Check(授權檢查)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant MW as middleware.CasbinRBAC
|
||||
participant Logic as ActorFromContext
|
||||
participant RBAC as RBACUseCase
|
||||
participant URR as UserRoleRepository
|
||||
participant Roles as RoleRepository
|
||||
participant Enf as casbin.SyncedEnforcer
|
||||
|
||||
MW->>Logic: actor (tenant, uid)
|
||||
MW->>RBAC: Check{tenant, uid, path, method}
|
||||
RBAC->>RBAC: enforcerFor(tenant)(lazy clone model + AddPolicies)
|
||||
RBAC->>URR: ListByUser(tenant, uid)
|
||||
RBAC->>Roles: ListByTenantAndIDs(過濾 status=open)
|
||||
loop role in roles(any-allow)
|
||||
RBAC->>Enf: EnforceEx(tenant, role.key, path, method)
|
||||
alt allow
|
||||
RBAC-->>MW: CheckResult{Allow=true, MatchedRoleKey, MatchedPolicyRow}
|
||||
end
|
||||
end
|
||||
MW->>MW: result.Allow ? next : 403 (errs.AuthForbidden)
|
||||
```
|
||||
|
||||
### 6.9 Pub/Sub 多 Pod Reload
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PodA as Pod A (Replace)
|
||||
participant Redis
|
||||
participant PodB as Pod B (Subscribe)
|
||||
participant PodC as Pod C (Subscribe)
|
||||
|
||||
PodA->>PodA: RolePermission.Replace + LoadPolicy(本地)
|
||||
PodA->>Redis: PUBLISH casbin:reload {tenant, ts}
|
||||
Redis-->>PodB: 推 message
|
||||
Redis-->>PodC: 推 message
|
||||
PodB->>PodB: handleReload → LoadPolicy(tenant)
|
||||
PodC->>PodC: handleReload → LoadPolicy(tenant)
|
||||
Note over PodB,PodC: 2-3ms 內三個 pod 同步
|
||||
```
|
||||
|
||||
> 兜底:每個 pod 可定時跑 `LoadAllPolicies`(5min cron,未在本模組內排程;建議 svc 層或 cron-worker 觸發)。掃 Redis `permission:casbin:rules:*` key 推導 tenant 列表。
|
||||
|
||||
### 6.10 GET /permissions/me(前端選單渲染)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Front as Frontend
|
||||
participant API as GET /permissions/me
|
||||
participant UC as AuthorizationQueryUseCase
|
||||
participant URR as UserRoleRepository
|
||||
participant Roles as RoleRepository
|
||||
participant RP as RolePermissionRepository
|
||||
participant Perms as PermissionRepository
|
||||
|
||||
Front->>API: Bearer JWT
|
||||
API->>UC: Me(tenant, uid, includeTree)
|
||||
UC->>URR: ListByUser
|
||||
UC->>Roles: ListByTenantAndIDs(過濾 status=open)
|
||||
UC->>RP: ListByRoles(roleIDs)
|
||||
UC->>Perms: GetByIDs(unique perm ids)
|
||||
UC->>UC: permission map = name→status
|
||||
alt includeTree
|
||||
UC->>UC: buildPermissionTree + filterOpenNodes
|
||||
end
|
||||
UC-->>API: { uid, tenant_id, roles, permissions, tree? }
|
||||
API-->>Front: 200 OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Casbin 模型(`etc/rbac.conf`)
|
||||
|
||||
```ini
|
||||
[request_definition]
|
||||
r = tenant, role, path, method
|
||||
|
||||
[policy_definition]
|
||||
p = tenant, role, path, methods, name
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.tenant == p.tenant && r.role == p.role && keyMatch2(r.path, p.path) && regexMatch(r.method, p.methods)
|
||||
```
|
||||
|
||||
- `keyMatch2`:支援 `/api/v1/members/*` 萬用 path
|
||||
- `regexMatch`:`GET|POST|PATCH` 多 method 同一 policy
|
||||
- 平台 Admin bypass 不寫進 matcher,由 middleware 預檢(保留 audit)
|
||||
|
||||
---
|
||||
|
||||
## 8. ServiceContext 注入
|
||||
|
||||
```go
|
||||
sc.PermissionCatalog // Permission catalog reader (tree / list / status)
|
||||
sc.PermissionRole // Role CRUD(含 system role 防呆)
|
||||
sc.PermissionRolePermission // Replace(含 parent closure)
|
||||
sc.PermissionUserRole // Assign / Revoke / ReplaceForSource
|
||||
sc.PermissionRoleMapping // 外部 group → Role.Key
|
||||
sc.PermissionAuthQuery // GET /me 用
|
||||
sc.PermissionRBAC // Casbin enforcer(Mongo+Redis 全到位才有)
|
||||
sc.PermissionRoleRepo // 給 SCIM / SyncFromX 等下游使用
|
||||
```
|
||||
|
||||
未啟用 Casbin 時 `PermissionRBAC == nil`,`Check()` 永遠 deny;middleware 會拒絕所有請求(除非 `AllowMissingActor=true`)。
|
||||
|
||||
---
|
||||
|
||||
## 9. HTTP API(前綴 `/api/v1/permissions`)
|
||||
|
||||
| Method | Path | Handler | 說明 |
|
||||
|--------|------|---------|------|
|
||||
| GET | `/catalog` | `getPermissionCatalog` | 全局 Catalog(tree=true 取樹狀) |
|
||||
| GET | `/me` | `getMePermissions` | 當前 user 的 role / permission map |
|
||||
| GET | `/roles` | `listRoles` | 租戶角色清單 |
|
||||
| POST | `/roles` | `createRole` | 建立角色(key 不可改) |
|
||||
| PATCH | `/roles/:id` | `updateRole` | 更新 display_name / status(system role 限制) |
|
||||
| DELETE | `/roles/:id` | `deleteRole` | 刪角色(system / 仍有指派 → 拒絕) |
|
||||
| GET | `/roles/:id/permissions` | `getRolePermissions` | 角色目前的 permission 集合 |
|
||||
| PUT | `/roles/:id/permissions` | `replaceRolePermissions` | 全量取代 + 補 parent + Pub/Sub reload |
|
||||
| GET | `/users/:uid/roles` | `listUserRoles` | 使用者目前指派的 role |
|
||||
| POST | `/users/:uid/roles` | `assignUserRole` | 指派角色(source 預設 manual) |
|
||||
| DELETE | `/users/:uid/roles/:role_id` | `revokeUserRole` | 撤銷單一角色 |
|
||||
| GET | `/role-mappings` | `listRoleMappings` | 外部映射列表(分頁) |
|
||||
| PUT | `/role-mappings` | `upsertRoleMapping` | Upsert 外部 group → Role.Key |
|
||||
| DELETE | `/role-mappings` | `deleteRoleMapping` | 刪除外部映射 |
|
||||
| POST | `/policy/reload` | `reloadPolicy` | 強制重載(單租戶或 `*`) |
|
||||
|
||||
完整錯誤碼註解參見 `generate/api/permission.api`,由 `make gen-doc` 出 OpenAPI。
|
||||
|
||||
---
|
||||
|
||||
## 10. 設定範例(`etc/gateway.dev.example.yaml`)
|
||||
|
||||
```yaml
|
||||
Permission:
|
||||
Casbin:
|
||||
Enabled: false # 預設關閉,啟用後 RBAC enforcement 生效
|
||||
ModelPath: etc/rbac.conf
|
||||
PolicyAdapter: auto # auto / redis / mongo
|
||||
Cache:
|
||||
UserRolesTTLSeconds: 300
|
||||
RolePermsTTLSeconds: 300
|
||||
CatalogTTLSeconds: 600
|
||||
Reload:
|
||||
Channel: casbin:reload
|
||||
DebounceMilliseconds: 200
|
||||
HeartbeatSeconds: 60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. CLI / 操作指南
|
||||
|
||||
```bash
|
||||
# 1) 建索引
|
||||
make mongo-index
|
||||
|
||||
# 2) 撰寫 / 修改 catalog
|
||||
$EDITOR internal/model/permission/seed/catalog.json
|
||||
|
||||
# 3) 全平台 seed catalog(不為任何 tenant 建 role)
|
||||
go run ./cmd/permission-seed -f etc/gateway.dev.yaml
|
||||
|
||||
# 4) 同時為 dev tenant seed 5 個 system role
|
||||
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001
|
||||
|
||||
# 5) 多租戶
|
||||
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001,TEN-100002
|
||||
|
||||
# 6) 只 reseed tenant role(catalog 已存在)
|
||||
go run ./cmd/permission-seed -f etc/gateway.dev.yaml -tenant TEN-100001 -skip-catalog
|
||||
|
||||
# 7) 強制全部 pod 重載 policy(HTTP)
|
||||
curl -X POST http://localhost:8888/api/v1/permissions/policy/reload \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Tenant-ID: TEN-100001" -H "X-UID: TEN-100001-OWNER" \
|
||||
-d '{"tenant_id": "*"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 中介層(middleware/casbin_rbac.go)
|
||||
|
||||
**現況:** middleware 已寫好,但 **尚未掛入 routes.go**(避免影響現有 dev 模式)。要啟用:
|
||||
|
||||
```go
|
||||
import perm "gateway/internal/middleware"
|
||||
|
||||
server.AddRoutes(routes,
|
||||
rest.WithMiddlewares(
|
||||
[]rest.Middleware{
|
||||
middleware.CloudEPJWT(serverCtx.AuthToken), // 已存在
|
||||
middleware.CasbinRBAC(serverCtx.PermissionRBAC, middleware.CasbinRBACOptions{
|
||||
AllowMissingActor: false,
|
||||
SkipPaths: map[string]struct{}{
|
||||
"/api/v1/health": {},
|
||||
},
|
||||
}),
|
||||
}...,
|
||||
),
|
||||
rest.WithPrefix("/api/v1/members"),
|
||||
)
|
||||
```
|
||||
|
||||
要先:
|
||||
1. 跑 seed CLI 把 catalog + system role 建好
|
||||
2. 為平台 admin tenant 建 `platform_super_admin` role + bypass allowlist
|
||||
3. 開啟 `Permission.Casbin.Enabled = true`
|
||||
4. 設好 `Permission.Reload.Channel`(多 pod 才需要)
|
||||
|
||||
---
|
||||
|
||||
## 13. 測試
|
||||
|
||||
```bash
|
||||
# 全模組 unit test
|
||||
go test ./internal/model/permission/...
|
||||
|
||||
# 含整合(需要 Mongo + Redis 在 docker compose 起著)
|
||||
make deps-up
|
||||
go test -tags=integration ./internal/model/permission/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 設計權衡 / 注意事項
|
||||
|
||||
| 議題 | 決策 | 原因 |
|
||||
|------|------|------|
|
||||
| Permission `name` 改名 | **禁止** | 被 RolePermission、UI i18n、Casbin policy.name 引用;廢棄走 `status=close` 然後新建 |
|
||||
| Role `key` 改名 | **禁止** | 外部 IdP mapping 直接綁 key;改名會切斷映射 |
|
||||
| `is_system` role 刪除 | 拒絕 | 平台預設角色保留 |
|
||||
| `is_system` role 改 status | 拒絕 | 維持平台預期行為 |
|
||||
| `manual` source ReplaceForSource | 拒絕 | 防 SyncFromX 誤洗手動指派 |
|
||||
| Permission 有 `*` 萬用 path | 不建議裸 `*`;至少帶資源根 | 防 keyMatch2 貪婪命中跨資源 |
|
||||
| Casbin 多 enforcer | 一 tenant 一個 enforcer,lazy 建 | 比一個 enforcer + filtered policy 簡單,且記憶體可預測 |
|
||||
| 多 pod 同步 | Pub/Sub 即時 + 5min cron 兜底 | 即時通知 + reboot 不漏 |
|
||||
| Pub/Sub client | 獨立 go-redis,不走 go-zero pool | go-zero 沒包 Subscribe,且 Subscribe 會佔住 conn |
|
||||
| Permission Catalog 改動 | seed CLI 即可(idempotent) | UI 端不直接改 catalog;seed JSON 是 SoT |
|
||||
|
||||
---
|
||||
|
||||
## 15. 後續工作
|
||||
|
||||
| 項目 | 預估 |
|
||||
|------|------|
|
||||
| Platform admin allowlist + audit log | 後續 |
|
||||
| RoleMapping 用 SyncFromX 落地(Zitadel / LDAP / SCIM)| 隨對應 SyncFromX usecase 推進 |
|
||||
| Policy reload cron worker(5 min) | 取自 svc 啟動 ticker |
|
||||
| Role permission 編輯 UI(不在 Gateway 內,由前端取資) | 前端 |
|
||||
| 細粒度欄位過濾(`.plain_code` 變體) | logic 層額外查 sub-permission |
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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_"}
|
||||
|
|
@ -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 != ""
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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": "健康檢查"
|
||||
}
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
redislib "gateway/internal/library/redis"
|
||||
permission "gateway/internal/model/permission/domain"
|
||||
"gateway/internal/model/permission/domain/entity"
|
||||
"gateway/internal/model/permission/domain/enum"
|
||||
domrepo "gateway/internal/model/permission/domain/repository"
|
||||
dom "gateway/internal/model/permission/domain/usecase"
|
||||
|
||||
"github.com/casbin/casbin/v2"
|
||||
casbinmodel "github.com/casbin/casbin/v2/model"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// RBACUseCaseParam injects all repos + Redis Pub/Sub client. ModelPath
|
||||
// must point at etc/rbac.conf; CasbinModelText overrides ModelPath when
|
||||
// non-empty (used by tests / embedded resources).
|
||||
type RBACUseCaseParam struct {
|
||||
Roles domrepo.RoleRepository
|
||||
Permissions domrepo.PermissionRepository
|
||||
RolePermissions domrepo.RolePermissionRepository
|
||||
UserRoles domrepo.UserRoleRepository
|
||||
Redis *redislib.Client
|
||||
ModelPath string
|
||||
CasbinModelText string
|
||||
ReloadChannel string
|
||||
}
|
||||
|
||||
// reloadEvent is the JSON payload published on the reload channel.
|
||||
type reloadEvent struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
type rbacUseCase struct {
|
||||
roles domrepo.RoleRepository
|
||||
perms domrepo.PermissionRepository
|
||||
rolePerms domrepo.RolePermissionRepository
|
||||
userRoles domrepo.UserRoleRepository
|
||||
redis *redislib.Client
|
||||
|
||||
enforcerMu sync.RWMutex
|
||||
enforcers map[string]*casbin.SyncedEnforcer
|
||||
|
||||
model casbinmodel.Model
|
||||
modelMu sync.Mutex
|
||||
modelTxt string
|
||||
|
||||
reloadChannel string
|
||||
stopSubscribe context.CancelFunc
|
||||
stopMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRBACUseCase wires the Casbin enforcer with the persistence layer.
|
||||
// Returns ErrCasbinNotConfigured when Redis is missing — Casbin's Redis
|
||||
// adapter and Pub/Sub require Redis to function.
|
||||
func NewRBACUseCase(param RBACUseCaseParam) (dom.RBACUseCase, error) {
|
||||
if param.Redis == nil || param.Redis.Zero() == nil {
|
||||
return nil, permission.ErrCasbinNotConfigured
|
||||
}
|
||||
channel := strings.TrimSpace(param.ReloadChannel)
|
||||
if channel == "" {
|
||||
channel = permission.PolicyReloadChannel
|
||||
}
|
||||
uc := &rbacUseCase{
|
||||
roles: param.Roles,
|
||||
perms: param.Permissions,
|
||||
rolePerms: param.RolePermissions,
|
||||
userRoles: param.UserRoles,
|
||||
redis: param.Redis,
|
||||
enforcers: make(map[string]*casbin.SyncedEnforcer),
|
||||
modelTxt: strings.TrimSpace(param.CasbinModelText),
|
||||
reloadChannel: channel,
|
||||
}
|
||||
if uc.modelTxt == "" && param.ModelPath != "" {
|
||||
mdl, err := casbinmodel.NewModelFromFile(param.ModelPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("permission: load casbin model: %w", err)
|
||||
}
|
||||
uc.model = mdl
|
||||
}
|
||||
return uc, nil
|
||||
}
|
||||
|
||||
// Check enforces (tenant, uid → role keys) ∩ policy. Multiple roles use
|
||||
// any-allow semantics: the first matching role short-circuits with
|
||||
// allow=true. The `r.role == p.role` matcher means we must call EnforceEx
|
||||
// once per role; that is acceptable because a member typically has 1–3
|
||||
// roles and the call is in-memory.
|
||||
func (uc *rbacUseCase) Check(ctx context.Context, req *dom.CheckRequest) (*dom.CheckResult, error) {
|
||||
if req == nil || req.TenantID == "" || req.UID == "" || req.Path == "" || req.Method == "" {
|
||||
return nil, permission.ErrInvalidCheckRequest
|
||||
}
|
||||
enforcer, err := uc.enforcerFor(ctx, req.TenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roleKeys, err := uc.roleKeysOf(ctx, req.TenantID, req.UID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(roleKeys) == 0 {
|
||||
return &dom.CheckResult{Allow: false}, nil
|
||||
}
|
||||
for _, key := range roleKeys {
|
||||
ok, matched, err := enforcer.EnforceEx(req.TenantID, key, req.Path, req.Method)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("permission: enforce: %w", err)
|
||||
}
|
||||
if ok {
|
||||
return &dom.CheckResult{
|
||||
Allow: true,
|
||||
MatchedRoleKey: key,
|
||||
MatchedPolicyRow: append([]string{permission.CasbinPolicyType}, matched...),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return &dom.CheckResult{Allow: false}, nil
|
||||
}
|
||||
|
||||
// LoadPolicy materialises role_permissions for a single tenant into
|
||||
// Casbin policy rules and atomically saves them via the Redis adapter.
|
||||
func (uc *rbacUseCase) LoadPolicy(ctx context.Context, tenantID string) error {
|
||||
rules, err := uc.buildRules(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enforcer, err := uc.enforcerFor(ctx, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enforcer.ClearPolicy()
|
||||
if len(rules) > 0 {
|
||||
if _, err := enforcer.AddPolicies(rules); err != nil {
|
||||
return fmt.Errorf("permission: add policies: %w", err)
|
||||
}
|
||||
}
|
||||
if err := uc.saveAdapter(ctx, tenantID, rules); err != nil {
|
||||
logx.WithContext(ctx).Errorf("permission: save adapter tenant=%s: %v", tenantID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAllPolicies refreshes policies for every tenant. Used by the
|
||||
// 5-minute cron fallback (see plan §6.11).
|
||||
func (uc *rbacUseCase) LoadAllPolicies(ctx context.Context) error {
|
||||
// Tenant list comes from the member module via Casbin keys; here we
|
||||
// scan the role collection's distinct tenant_id. For simplicity we
|
||||
// reload only tenants that have at least one role.
|
||||
roles, err := uc.allTenantsWithRoles(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tenantID := range roles {
|
||||
if err := uc.LoadPolicy(ctx, tenantID); err != nil {
|
||||
logx.WithContext(ctx).Errorf("permission: reload tenant=%s: %v", tenantID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BroadcastReload publishes a tenant-scoped reload event over Redis
|
||||
// Pub/Sub. Other pods (and this pod itself) consume it to re-LoadPolicy.
|
||||
func (uc *rbacUseCase) BroadcastReload(ctx context.Context, tenantID string) error {
|
||||
if uc.redis == nil || uc.redis.Zero() == nil {
|
||||
return nil
|
||||
}
|
||||
if tenantID == "" {
|
||||
tenantID = permission.PolicyReloadAllToken
|
||||
}
|
||||
payload, err := json.Marshal(reloadEvent{TenantID: tenantID, TS: time.Now().UnixMilli()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = uc.redis.Zero().PublishCtx(ctx, uc.reloadChannel, string(payload))
|
||||
return err
|
||||
}
|
||||
|
||||
// StartReloadSubscriber spins a goroutine that reads from the Redis
|
||||
// Pub/Sub channel and calls LoadPolicy for each event. Idempotent: a
|
||||
// second call replaces the prior subscription.
|
||||
func (uc *rbacUseCase) StartReloadSubscriber(ctx context.Context) error {
|
||||
uc.StopReloadSubscriber()
|
||||
pubsub := uc.redis.PubSubClient()
|
||||
if pubsub == nil {
|
||||
return nil
|
||||
}
|
||||
subCtx, cancel := context.WithCancel(ctx)
|
||||
uc.stopMu.Lock()
|
||||
uc.stopSubscribe = cancel
|
||||
uc.stopMu.Unlock()
|
||||
|
||||
sub := pubsub.Subscribe(subCtx, uc.reloadChannel)
|
||||
if _, err := sub.Receive(subCtx); err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("permission: subscribe reload channel: %w", err)
|
||||
}
|
||||
ch := sub.Channel()
|
||||
go func() {
|
||||
defer func() { _ = sub.Close() }()
|
||||
for {
|
||||
select {
|
||||
case <-subCtx.Done():
|
||||
return
|
||||
case msg, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
uc.handleReload(subCtx, msg.Payload)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopReloadSubscriber cancels the subscriber goroutine (best-effort).
|
||||
func (uc *rbacUseCase) StopReloadSubscriber() {
|
||||
uc.stopMu.Lock()
|
||||
defer uc.stopMu.Unlock()
|
||||
if uc.stopSubscribe != nil {
|
||||
uc.stopSubscribe()
|
||||
uc.stopSubscribe = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) handleReload(ctx context.Context, payload string) {
|
||||
var ev reloadEvent
|
||||
if err := json.Unmarshal([]byte(payload), &ev); err != nil {
|
||||
logx.WithContext(ctx).Errorf("permission: invalid reload payload: %s", payload)
|
||||
return
|
||||
}
|
||||
if ev.TenantID == permission.PolicyReloadAllToken || ev.TenantID == "" {
|
||||
if err := uc.LoadAllPolicies(ctx); err != nil {
|
||||
logx.WithContext(ctx).Errorf("permission: reload all: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := uc.LoadPolicy(ctx, ev.TenantID); err != nil {
|
||||
logx.WithContext(ctx).Errorf("permission: reload tenant=%s: %v", ev.TenantID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) enforcerFor(ctx context.Context, tenantID string) (*casbin.SyncedEnforcer, error) {
|
||||
uc.enforcerMu.RLock()
|
||||
if e, ok := uc.enforcers[tenantID]; ok {
|
||||
uc.enforcerMu.RUnlock()
|
||||
return e, nil
|
||||
}
|
||||
uc.enforcerMu.RUnlock()
|
||||
|
||||
uc.enforcerMu.Lock()
|
||||
defer uc.enforcerMu.Unlock()
|
||||
if e, ok := uc.enforcers[tenantID]; ok {
|
||||
return e, nil
|
||||
}
|
||||
mdl, err := uc.cloneModel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enforcer, err := casbin.NewSyncedEnforcer(mdl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("permission: new enforcer: %w", err)
|
||||
}
|
||||
enforcer.EnableAutoSave(false)
|
||||
uc.enforcers[tenantID] = enforcer
|
||||
|
||||
rules, err := uc.buildRules(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
if _, err := enforcer.AddPolicies(rules); err != nil {
|
||||
return nil, fmt.Errorf("permission: seed policies: %w", err)
|
||||
}
|
||||
}
|
||||
return enforcer, nil
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) cloneModel() (casbinmodel.Model, error) {
|
||||
uc.modelMu.Lock()
|
||||
defer uc.modelMu.Unlock()
|
||||
if uc.modelTxt != "" {
|
||||
return casbinmodel.NewModelFromString(uc.modelTxt)
|
||||
}
|
||||
if uc.model == nil {
|
||||
return nil, errors.New("permission: casbin model not loaded")
|
||||
}
|
||||
// casbin/model is not safe for concurrent enforcers in some versions;
|
||||
// dump+parse keeps each enforcer isolated.
|
||||
return casbinmodel.NewModelFromString(uc.model.ToText())
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) buildRules(ctx context.Context, tenantID string) ([][]string, error) {
|
||||
roles, err := uc.roles.ListByTenant(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, wrapRepoErr(err)
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
roleByID := make(map[string]*entity.Role, len(roles))
|
||||
roleIDs := make([]string, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
if role.Status != enum.StatusOpen {
|
||||
continue
|
||||
}
|
||||
roleByID[role.ID.Hex()] = role
|
||||
roleIDs = append(roleIDs, role.ID.Hex())
|
||||
}
|
||||
rps, err := uc.rolePerms.ListByRoles(ctx, tenantID, roleIDs)
|
||||
if err != nil {
|
||||
return nil, wrapRepoErr(err)
|
||||
}
|
||||
if len(rps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
permIDSet := make(map[string]struct{}, len(rps))
|
||||
for _, rp := range rps {
|
||||
permIDSet[rp.PermissionID] = struct{}{}
|
||||
}
|
||||
ids := make([]string, 0, len(permIDSet))
|
||||
for id := range permIDSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
perms, err := uc.perms.GetByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, wrapRepoErr(err)
|
||||
}
|
||||
permByID := make(map[string]*entity.Permission, len(perms))
|
||||
for _, perm := range perms {
|
||||
permByID[perm.ID.Hex()] = perm
|
||||
}
|
||||
rules := make([][]string, 0, len(rps))
|
||||
for _, rp := range rps {
|
||||
role, ok := roleByID[rp.RoleID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
perm, ok := permByID[rp.PermissionID]
|
||||
if !ok || !perm.IsLeaf() || perm.Status != enum.StatusOpen {
|
||||
continue
|
||||
}
|
||||
rules = append(rules, []string{
|
||||
tenantID,
|
||||
role.Key,
|
||||
perm.HTTPPath,
|
||||
perm.HTTPMethods,
|
||||
perm.Name,
|
||||
})
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) allTenantsWithRoles(ctx context.Context) ([]string, error) {
|
||||
// Casbin reload is best-effort across pods; we use the Redis cluster
|
||||
// to remember which tenant keys exist. Empty set ⇒ nothing to do.
|
||||
if uc.redis == nil || uc.redis.Zero() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys, err := uc.redis.Zero().KeysCtx(ctx, permission.CasbinRulesRedisKey.String()+":*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := permission.CasbinRulesRedisKey.String() + ":"
|
||||
tenantIDs := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
tenantIDs = append(tenantIDs, strings.TrimPrefix(key, prefix))
|
||||
}
|
||||
return tenantIDs, nil
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) saveAdapter(ctx context.Context, tenantID string, rules [][]string) error {
|
||||
adapter, err := newRedisAdapterFromClient(uc.redis)
|
||||
if err != nil || adapter == nil {
|
||||
return err
|
||||
}
|
||||
return adapter.SaveAll(ctx, tenantID, rules)
|
||||
}
|
||||
|
||||
// newRedisAdapterFromClient is implemented in casbin_adapter_bridge.go to
|
||||
// keep the import surface narrow (avoid pulling repository into usecase).
|
||||
func newRedisAdapterFromClient(client *redislib.Client) (domrepo.CasbinPolicyAdapter, error) {
|
||||
return RedisAdapterFactory(client)
|
||||
}
|
||||
|
||||
// RedisAdapterFactory is plugged in by module.go (DI seam). Tests can
|
||||
// override by assigning a stub.
|
||||
var RedisAdapterFactory = func(_ *redislib.Client) (domrepo.CasbinPolicyAdapter, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (uc *rbacUseCase) roleKeysOf(ctx context.Context, tenantID, uid string) ([]string, error) {
|
||||
urs, err := uc.userRoles.ListByUser(ctx, tenantID, uid)
|
||||
if err != nil {
|
||||
return nil, wrapRepoErr(err)
|
||||
}
|
||||
if len(urs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
roleIDs := make([]string, 0, len(urs))
|
||||
for _, ur := range urs {
|
||||
roleIDs = append(roleIDs, ur.RoleID)
|
||||
}
|
||||
roles, err := uc.roles.ListByTenantAndIDs(ctx, tenantID, roleIDs)
|
||||
if err != nil {
|
||||
return nil, wrapRepoErr(err)
|
||||
}
|
||||
out := make([]string, 0, len(roles))
|
||||
for _, role := range roles {
|
||||
if role.Status != enum.StatusOpen {
|
||||
continue
|
||||
}
|
||||
out = append(out, role.Key)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ dom.RBACUseCase = (*rbacUseCase)(nil)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue