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