diff --git a/CLAUDE.md b/CLAUDE.md index ed730ee..3066389 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,8 +99,6 @@ team-api/ ├── docs/ # 项目文档 │ ├── 开发计划-v2/ # 分周期开发计划(周期一~六) │ └── 协议文档/ # 协议相关文档 -├── new-api/ # 参考项目(只读,不修改) -├── sub2api/ # 参考项目(只读,不修改) ├── main.go # 应用入口 ├── go.mod └── Makefile @@ -491,3 +489,7 @@ cmd(路由注册) | `gf gen pb` | 生成 protobuf 文件 | 需要时 | | `gf run` | 热编译运行开发服务器 | 开发调试 | | `gf build` | 编译生产二进制 | 构建部署 | + + +## AI工具要求 +不允许在提交内容中加入AI工具相关的身份表示和内容,不允许将AI作为仓库贡献者提交 \ No newline at end of file diff --git a/api/admin/admin.go b/api/admin/admin.go index 3481f55..7d9091d 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -209,7 +209,6 @@ type IAdminV1 interface { PaymentSettingsUpdate(ctx context.Context, req *v1.PaymentSettingsUpdateReq) (res *v1.PaymentSettingsUpdateRes, err error) AdminPermissionList(ctx context.Context, req *v1.AdminPermissionListReq) (res *v1.AdminPermissionListRes, err error) AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error) - AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error) AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error) PlanList(ctx context.Context, req *v1.PlanListReq) (res *v1.PlanListRes, err error) PlanCreate(ctx context.Context, req *v1.PlanCreateReq) (res *v1.PlanCreateRes, err error) diff --git a/api/admin/v1/permission.go b/api/admin/v1/permission.go index 64dc26d..1211353 100644 --- a/api/admin/v1/permission.go +++ b/api/admin/v1/permission.go @@ -28,19 +28,12 @@ type AdminPermissionUpdateReq struct { type AdminPermissionUpdateRes struct{} -// AdminDataScopeUpdateReq 更新管理员数据范围请求 -type AdminDataScopeUpdateReq struct { - g.Meta `path:"/users/{id}/data-scopes" method:"put" mime:"json" tags:"管理后台-权限管理" summary:"更新管理员数据范围"` - Id int64 `json:"id" in:"path" v:"required" dc:"管理员ID"` - DataScopes []DataScopeInput `json:"data_scopes" dc:"数据范围列表"` -} - -type DataScopeInput struct { - ScopeType string `json:"scope_type" v:"required|in:all,tenant_group,tenant#请选择范围类型|范围类型无效" dc:"范围类型"` - ScopeValue string `json:"scope_value" dc:"范围值"` -} - -type AdminDataScopeUpdateRes struct{} +// 数据范围(sys_admin_data_scopes)的写接口已下线。 +// +// 该表可以配置「全部 / 租户组 / 指定租户」,但全站没有任何查询在读它做过滤 +// (GetDataScopes 零调用方),配出来的范围不会生效。留着一个能存、能显示、 +// 却不约束任何数据的开关,比没有更危险:以为限定了范围的账号实际能看全量数据。 +// 表与存量数据保留,待数据范围真正落到查询链路上再恢复配置入口。 // AdminAllPermissionsReq 获取所有可用权限点请求 type AdminAllPermissionsReq struct { diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 96a31f8..ed8809b 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -892,3 +892,28 @@ s.Start() - **禁止用 `g.Cfg()` 读取任何注册表里的 key**:同名 key 在配置文件里恰好存在时会造成"双真相源",配置文件的值会静默覆盖管理员在后台设置的值(或反之),且 `MustGet` 对键缺失不报错,分裂长期不可见。 排查信号:设置页某配置"保存了但不生效";`grep -rn "g.Cfg()"` 命中的 key 同时出现在 `settings_registry.go` 中。 + +### 2026-09-03(三):ghttp 归一化尾部斜杠但不归一化中间的重复斜杠 + +**问题**:管理后台的授权(`AdminPermissionGuard`)按 `r.URL.Path` 做精确路径 / 前缀 / 前缀+后缀三段匹配。若框架对路径的归一化范围与预期不符,同一个 handler 就可能有多种匹配结果——加一个斜杠就绕到另一条(可能更弱的)规则上。 + +**框架实测行为**(GoFrame v2,`ghttp` 路由,实测端口探针验证): + +| 请求路径 | 是否匹配到路由 | handler 里的 `r.URL.Path` | +|----------|----------------|---------------------------| +| `/api/admin/roles` | ✅ | `/api/admin/roles` | +| `/api/admin/roles/` | ✅ | `/api/admin/roles`(**尾部斜杠已被去掉**) | +| `/api/admin/roles//` | ✅ | `/api/admin/roles`(同上) | +| `/api/admin//roles` | ✅ | `/api/admin//roles`(**中间的重复斜杠原样保留**) | +| `/api/admin/./roles`、`/api/x/../admin/roles` | ❌ 404 | — | +| `/api/admin/ROLES` | ❌ 404 | — | + +即:**尾部斜杠会被归一化,路径中间的空段不会**;`.` / `..` 段与大小写变体压根匹配不到路由。 + +**正确做法(通用规则)**: + +- **任何按 `r.URL.Path` 做字符串匹配的中间件(鉴权、限流、审计采样、灰度路由),都不能假设路径已规范化**。中间的 `//` 会带着原样路径进到 handler,字符串规则却按规范形式书写,两者对不上。 +- 处理方式选**拒收**而非**重写**:重写会把原本因规则不匹配而被拒的变体变成放行(等于放宽授权面),拒收既不放宽也不留缝隙。本项目在 `AdminPermissionGuard` 入口用 `isCanonicalPath` 直接 403,正常客户端不会产生这类路径。 +- 尾部斜杠已由框架处理,无需自行 trim,**也不要因此把 `/xxx/` 当作合法输入去写规则**——handler 永远看不到它。 + +排查信号:某接口"多加一个斜杠就能绕过权限/限流";中间件按路径匹配的规则表里出现了同一 handler 的两条规则。 diff --git a/internal/controller/admin/admin_v1_permission.go b/internal/controller/admin/admin_v1_permission.go index aea4afe..c94c44b 100644 --- a/internal/controller/admin/admin_v1_permission.go +++ b/internal/controller/admin/admin_v1_permission.go @@ -13,9 +13,6 @@ func (c *ControllerV1) AdminPermissionList(ctx context.Context, req *v1.AdminPer func (c *ControllerV1) AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error) { return service.Admin().UpdateUserPermissions(ctx, req) } -func (c *ControllerV1) AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error) { - return service.Admin().UpdateUserDataScopes(ctx, req) -} func (c *ControllerV1) AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error) { return service.Admin().GetAllPermissions(ctx, req) } diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go index e57fab5..b960f15 100644 --- a/internal/logic/admin/admin_user.go +++ b/internal/logic/admin/admin_user.go @@ -411,17 +411,24 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt } // UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。 +// +// 解锁是本文件里唯一漏掉 assertCanManageAdminUser 的账号处置动作:锁定本身是暴力破解 +// 的防线,非超管若能解锁超级管理员账号,就等于可以无限次重置对方的失败计数继续爆破。 func (s *sAdmin) UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error) { var user *struct { - Id int64 `json:"id"` + Id int64 `json:"id"` + Role string `json:"role"` } - err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Scan(&user) + err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("id, role").Scan(&user) if err = common.IgnoreScanNoRows(err); err != nil { return nil, err } if user == nil { return nil, common.NewNotFoundError("管理员") } + if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil { + return nil, err + } _, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Data(map[string]interface{}{ "failed_attempts": 0, diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index 523cfc5..5bec54d 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -133,7 +133,7 @@ var predefinedPermissionGroups = []v1.PermissionGroup{ Name: "member", Label: "成员管理", Permissions: []string{ - "member:view", "member:import", "member:model_scope", + "member:view", "member:import", "member:manage", "member:model_scope", }, }, { @@ -272,62 +272,6 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss return nil, nil } -// UpdateUserDataScopes updates data scopes for an admin user. -func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) { - // Check if target is super_admin - var user *struct { - Role string `json:"role"` - } - err := dao.SysAdminUsers.Ctx(ctx). - Where("id", req.Id).Scan(&user) - if err = common.IgnoreScanNoRows(err); err != nil { - return nil, err - } - if user == nil { - return nil, common.NewNotFoundError("用户") - } - if user.Role == "super_admin" { - return nil, common.NewBadRequestError("超级管理员无需配置数据范围") - } - if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil { - return nil, err - } - - // 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。 - err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - // Delete existing data scopes - _, err := dao.SysAdminDataScopes.Ctx(ctx). - Where("admin_user_id", req.Id). - Delete() - if err != nil { - return err - } - - // Insert new data scopes - if len(req.DataScopes) > 0 { - data := make([]do.SysAdminDataScopes, len(req.DataScopes)) - for i, sc := range req.DataScopes { - data[i] = do.SysAdminDataScopes{ - AdminUserId: req.Id, - ScopeType: sc.ScopeType, - ScopeValue: sc.ScopeValue, - } - } - _, err = dao.SysAdminDataScopes.Ctx(ctx).Data(data).Insert() - if err != nil { - return err - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return nil, nil -} - // tierLabels 是档位的中文标签,随元数据下发给前端,避免前端硬编码一份。 var tierLabels = map[string]string{ TierNone: "无", @@ -393,6 +337,11 @@ func HasPermission(ctx context.Context, userID int64, role string, permission st // GetDataScopes returns data scopes for an admin user. // super_admin always returns "all". +// +// ⚠️ 当前无调用方:数据范围只被存储与回显,没有任何查询按它过滤,配置了也不生效。 +// 写接口已随之下线(见 api/admin/v1/permission.go)。要让这个特性真正成立, +// 必须先把它接进各列表查询的 WHERE 条件,再恢复配置入口 —— 在那之前不要仅凭 +// 本函数存在就认为数据范围已被强制执行。 func GetDataScopes(ctx context.Context, userID int64, role string) ([]v1.DataScopeItem, error) { if role == "super_admin" { return []v1.DataScopeItem{{ScopeType: "all"}}, nil diff --git a/internal/logic/admin/role.go b/internal/logic/admin/role.go index 0ff0424..b1c79e6 100644 --- a/internal/logic/admin/role.go +++ b/internal/logic/admin/role.go @@ -47,8 +47,8 @@ var tierPermissions = map[string]map[string][]string{ }, "member": { TierRead: {"member:view"}, - TierOperate: {"member:view", "member:import"}, - TierFull: {"member:view", "member:import", "member:model_scope"}, + TierOperate: {"member:view", "member:import", "member:manage"}, + TierFull: {"member:view", "member:import", "member:manage", "member:model_scope"}, }, "channel": { TierRead: {"channel:view"}, @@ -137,6 +137,7 @@ var dangerousPermissions = map[string]string{ "user:create": "可创建管理员账号", "user:edit": "可修改管理员账号与其角色(含提权)", "user:delete": "可删除管理员账号", + "member:manage": "可重置任意租户成员的密码(等同接管该成员账号)", } // tierOrder 定义档位由低到高的顺序,用于「权限点集合 → 档位」的反向推导。 diff --git a/internal/logic/admin/role_crud.go b/internal/logic/admin/role_crud.go index 1a7c202..6e4961f 100644 --- a/internal/logic/admin/role_crud.go +++ b/internal/logic/admin/role_crud.go @@ -30,7 +30,7 @@ var builtinRoleDefaults = map[string][]string{ "channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete", "model:view", "model:create", "model:edit", "model:delete", "tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close", - "member:view", "member:import", "member:model_scope", + "member:view", "member:import", "member:manage", "member:model_scope", "plan:view", "plan:create", "plan:edit", "plan:delete", "billing:view", "billing:export", "billing:refund", "order:view", "order:refund", @@ -52,7 +52,7 @@ var builtinRoleDefaults = map[string][]string{ "channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete", "model:view", "model:create", "model:edit", "model:delete", "tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", - "member:view", "member:import", "member:model_scope", + "member:view", "member:import", "member:manage", "member:model_scope", "plan:view", "plan:create", "plan:edit", "plan:delete", "billing:view", "order:view", diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 6700276..7b7e8bc 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -815,7 +815,15 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda } // ResetMemberPassword resets a member's password. Only admins can reset other members' passwords. +// +// 角色校验此前缺失:只挡了「不能重置自己」和「不能重置 owner」,任意 member 都能改掉 +// 同组织 admin 的密码并登录该账号,等于组织内横向提权。与相邻的 UnlockMember / +// RemoveMember / UpdateMemberRole 保持同一道闸门。 func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } if err := requireTeamEnabled(ctx); err != nil { return nil, err } diff --git a/internal/logic/tenant/member_import.go b/internal/logic/tenant/member_import.go index b25f3e1..b990b51 100644 --- a/internal/logic/tenant/member_import.go +++ b/internal/logic/tenant/member_import.go @@ -34,7 +34,14 @@ type ImportResult struct { } // MemberImport parses CSV content, validates, creates an import record. +// +// 角色校验此前缺失:批量导入会直接创建租户成员账号(并按套餐占用席位), +// 与 MemberCreate / MemberInvite 是同一类动作,必须同为 owner/admin。 func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } if err := requireTeamEnabled(ctx); err != nil { return nil, err } diff --git a/internal/logic/tenant/plugin.go b/internal/logic/tenant/plugin.go index add1c79..2f659f8 100644 --- a/internal/logic/tenant/plugin.go +++ b/internal/logic/tenant/plugin.go @@ -5,6 +5,7 @@ import ( v1 "github.com/qianfree/team-api/api/tenant/v1" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/middleware" "github.com/qianfree/team-api/internal/plugin" @@ -76,7 +77,23 @@ func (s *sTenant) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDe }, nil } +// assertPluginManager 校验插件的写操作权限。 +// +// 插件配置与启停是【组织级】设置:一次改动对整个租户的全部成员生效,配置里还可能包含 +// 第三方凭据。此前三个写接口只取了 tenantID 就直接落库,任意 member 都能改掉或关停 +// 全组织的插件,与 List/Detail(读)同权。读保持全员可见,写收敛到 owner/admin。 +func assertPluginManager(ctx context.Context) error { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return common.NewForbiddenError("需要 owner 或 admin 权限") + } + return nil +} + func (s *sTenant) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (*v1.TenantPluginConfigUpdateRes, error) { + if err := assertPluginManager(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := plugin.UpdatePluginConfigForTenant(ctx, req.Name, tenantID, req.Config); err != nil { return nil, err @@ -85,6 +102,9 @@ func (s *sTenant) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPl } func (s *sTenant) TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEnableReq) (*v1.TenantPluginEnableRes, error) { + if err := assertPluginManager(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := plugin.EnableForTenant(ctx, req.Name, tenantID); err != nil { return nil, err @@ -93,6 +113,9 @@ func (s *sTenant) TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEn } func (s *sTenant) TenantPluginDisable(ctx context.Context, req *v1.TenantPluginDisableReq) (*v1.TenantPluginDisableRes, error) { + if err := assertPluginManager(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if err := plugin.DisableForTenant(ctx, req.Name, tenantID); err != nil { return nil, err diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go index 56e45d1..c4f6045 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -355,7 +355,14 @@ func (s *sTenant) ProjectArchive(ctx context.Context, req *v1.TenantProjectArchi } // ProjectUnarchive restores an archived project. Keys are NOT auto-restored. +// +// 角色校验此前缺失:Create/Update/Archive 都要求 owner/admin,唯独恢复归档没拦, +// 任意 member 都能把管理员刚归档的项目恢复回来(归档是治理动作,撤销它同样是)。 func (s *sTenant) ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUnarchiveReq) (*v1.TenantProjectUnarchiveRes, error) { + role := middleware.GetUserRole(ctx) + if role != "owner" && role != "admin" { + return nil, common.NewForbiddenError("需要 owner 或 admin 权限") + } tenantID := middleware.GetTenantID(ctx) var project *struct { diff --git a/internal/middleware/admin_auth.go b/internal/middleware/admin_auth.go index 1e2beab..669d9f0 100644 --- a/internal/middleware/admin_auth.go +++ b/internal/middleware/admin_auth.go @@ -77,11 +77,15 @@ func AdminAuth(r *ghttp.Request) { return } - // Verify user still exists and is active + // Verify user still exists and is active. + // role 一并取出:super_admin 会短路整套鉴权(HasPermission 直接放行、 + // assertSuperAdmin 与 superAdminOnlyRules 都只看它),是全站最高权级的判据, + // 因此必须以数据库为准,不能沿用签发时写进 token 的 claims.Role —— 否则给某人 + // 临时提为超管再降回来后,其旧 token 在有效期内仍是超管(降权无法即时生效)。 var user *entity.SysAdminUsers err = dao.SysAdminUsers.Ctx(r.Context()). Where("id", claims.UserID). - Fields("status"). + Fields("status, role"). Scan(&user) if err != nil { response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized) @@ -95,7 +99,7 @@ func AdminAuth(r *ghttp.Request) { // Set auth context r.SetCtxVar(CtxKeyUserID, claims.UserID) r.SetCtxVar(CtxKeyUserType, claims.UserType) - r.SetCtxVar(CtxKeyRole, claims.Role) + r.SetCtxVar(CtxKeyRole, user.Role) r.SetCtxVar(CtxKeySessionID, claims.SessionID) r.SetCtxVar(CtxKeyJti, claims.ID) diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 298b1ba..42c6184 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -10,42 +10,11 @@ import ( "github.com/qianfree/team-api/internal/response" ) -// RequirePermission returns middleware that checks if the admin user has the required permission. -func RequirePermission(permission string) func(r *ghttp.Request) { - return func(r *ghttp.Request) { - role := GetUserRole(r.Context()) - userID := GetUserID(r.Context()) - - if role == "super_admin" { - r.Middleware.Next() - return - } - - if !admin.HasPermission(r.Context(), userID, role, permission) { - response.ErrorMsg(r, consts.CodeForbidden, "缺少权限:"+permission) - return - } - - r.Middleware.Next() - } -} - -// RequireTenantRole returns middleware that checks if the tenant user has the required role. -func RequireTenantRole(roles ...string) func(r *ghttp.Request) { - roleSet := make(map[string]bool) - for _, r := range roles { - roleSet[r] = true - } - - return func(r *ghttp.Request) { - role := GetUserRole(r.Context()) - if !roleSet[role] { - response.ErrorMsg(r, consts.CodeForbidden, consts.MsgForbidden) - return - } - r.Middleware.Next() - } -} +// 说明:曾有 RequirePermission / RequireTenantRole 两个「按路由挂载」的中间件, +// 均无任何调用方,已删除。管理后台的授权统一由 AdminPermissionGuard + adminPermissionRules +// 集中判定(默认拒绝、规则完整性由 rbac_coverage_test.go 保证);租户控制台的角色校验 +// 在 logic 层逐方法进行。留着一个从未挂载的 RequireTenantRole 会让人误以为 +// /api/tenant 有路由级角色闸门,实际并没有。 // adminPermissionRules defines the mapping from route patterns to permission points. // Unmapped admin routes are denied by default in AdminPermissionGuard. @@ -139,7 +108,7 @@ var adminPermissionRules = []permissionRule{ {method: "POST", prefix: "/api/admin/wallets/", suffix: "/offline-recharge", perm: "billing:refund"}, {method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release", perm: "billing:refund"}, {method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release-all", perm: "billing:refund"}, - {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"}, + {method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:refund"}, // ── plan 套餐管理 ── {method: "GET", path: "/api/admin/plans", perm: "plan:view"}, @@ -154,7 +123,9 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/orders/export", perm: "order:view"}, {method: "GET", prefix: "/api/admin/orders/", perm: "order:view"}, {method: "POST", prefix: "/api/admin/orders/", suffix: "/refund", perm: "order:refund"}, - {method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:view"}, + // 手动完成订单会走 markOrderPaidByAdmin + payment.FulfillOrder:无支付凭证直接履约 + // (充值入钱包 / 发放套餐),是资金动作而非订单查看,按退款同级管控。 + {method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:refund"}, // ── operation 内容运营 ── {method: "GET", path: "/api/admin/changelogs", perm: "operation:view"}, @@ -195,7 +166,9 @@ var adminPermissionRules = []permissionRule{ // ── audit 审计日志 ── {method: "GET", path: "/api/admin/audit/config", perm: "audit:view"}, - {method: "PUT", path: "/api/admin/audit/config", perm: "audit:view"}, + // 写 sys_options.audit_level(全局审计级别,可设为 none 关闭全站审计采集): + // 属于系统配置而非审计查看,否则只读档的技术支持可以自行关掉审计留痕。 + {method: "PUT", path: "/api/admin/audit/config", perm: "system:edit"}, {method: "GET", path: "/api/admin/audit/operation-logs", perm: "audit:view"}, {method: "GET", path: "/api/admin/audit/operation-logs/export", perm: "audit:export"}, {method: "GET", path: "/api/admin/audit/request-logs", perm: "audit:view"}, @@ -270,10 +243,12 @@ var adminPermissionRules = []permissionRule{ // ── member 成员管理(管理后台维度) ── {method: "GET", path: "/api/admin/members", perm: "member:view"}, {method: "POST", path: "/api/admin/members", perm: "member:import"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:view"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:view"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:view"}, - {method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:view"}, + // 禁用/启用/改密/解锁都是对租户成员账号的处置(改密等于接管该成员账号, + // 进而可用其 API Key 消耗租户余额),必须走写权限点,不能沿用只读的 member:view。 + {method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:manage"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:manage"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:manage"}, + {method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:manage"}, // ── redemption 兑换码管理 ── {method: "GET", path: "/api/admin/redemptions", perm: "redemption:view"}, @@ -299,7 +274,6 @@ var adminPermissionRules = []permissionRule{ {method: "GET", path: "/api/admin/permissions", perm: "user:view"}, {method: "GET", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:view"}, {method: "PUT", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:edit"}, - {method: "PUT", prefix: "/api/admin/users/", suffix: "/data-scopes", perm: "user:edit"}, // ── admin session and security ── {method: "POST", path: "/api/admin/auth/logout", perm: "self:access"}, @@ -405,6 +379,16 @@ type permissionRule struct { // 兜底保证:super_admin 在规则匹配之前短路放行,因此即便规则有遗漏也不会把系统锁死, // 超管始终可以进入并修复。规则的完整性由 rbac_coverage_test.go 在 CI 阶段保证。 func AdminPermissionGuard(r *ghttp.Request) { + // 路径必须是规范形式:GoFrame 不会归一化重复斜杠,/api/admin//roles 会带着原样路径 + // 进到 handler,而规则表按精确路径/前缀匹配,同一个 handler 就有了两种匹配结果。 + // 目前默认拒绝把这类变体都挡在外面,但那是「恰好安全」——只要将来某条前缀规则的 + // 权限点比精确规则弱,加一个斜杠就能降档。与其依赖这个巧合,不如直接拒收非规范路径: + // 正常客户端不会产生它,攻击者才需要它。 + if !isCanonicalPath(r.URL.Path) { + response.ErrorMsg(r, consts.CodeForbidden, "非法的请求路径") + return + } + if isAdminPublicPath(r.URL.Path) { r.Middleware.Next() return @@ -443,6 +427,22 @@ func AdminPermissionGuard(r *ghttp.Request) { r.Middleware.Next() } +// isCanonicalPath 判断路径是否为规范形式:不含空路径段("//")与 "." / ".." 段。 +// +// 只做判定不做重写:重写会把「当前被默认拒绝挡住的变体」变成放行,等于放宽; +// 直接拒收既不放宽也不留下将来被利用的缝隙。 +func isCanonicalPath(path string) bool { + if path == "" { + return false + } + for _, seg := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if seg == "" || seg == "." || seg == ".." { + return false + } + } + return true +} + // matchPermission finds the permission required for the given method and path. // Matching priority: exact path > prefix+suffix > prefix-only (catch-all). // This three-phase approach eliminates rule ordering dependencies. diff --git a/internal/middleware/rbac_coverage_test.go b/internal/middleware/rbac_coverage_test.go index bc07a9c..d9e23a8 100644 --- a/internal/middleware/rbac_coverage_test.go +++ b/internal/middleware/rbac_coverage_test.go @@ -225,6 +225,42 @@ func TestMatchedPermissionsAreGrantable(t *testing.T) { } } +// writeOnViewPermExceptions 列出「写方法但语义上是读」的接口白名单。 +// +// 加白之前先问一句:这个接口有没有改变任何持久状态?只有答案是「没有,用 POST 纯粹是 +// 因为查询参数太复杂放不进 query string」时才可以加,并写明理由。 +var writeOnViewPermExceptions = map[string]string{ + // 导出模型配置为 JSON:只读取并序列化现有配置,不写库。用 POST 是因为筛选条件是 + // 一个可能很大的模型 ID 数组。 + "POST /api/admin/models/export-json": "纯导出,不改变任何状态", +} + +// TestWriteRoutesDoNotUseViewPermissions 保证写接口不会挂在只读权限点上。 +// +// 这是本次权限审计里出现最多的一类缺陷:接口按 :view 配置,于是「技术支持」这类被明确 +// 定位成只读的角色,实际拥有了改密、改配置、履约订单的能力。权限点的档位语义 +// (read/operate/full)只有在规则表遵守它时才成立,靠人工 review 守不住这条线。 +func TestWriteRoutesDoNotUseViewPermissions(t *testing.T) { + for _, r := range collectAdminRoutes(t) { + if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { + continue + } + path := concreteFromPattern(r.Path) + if isAdminPublicPath(path) || isSuperAdminOnly(r.Method, path) { + continue + } + if _, ok := writeOnViewPermExceptions[r.Method+" "+r.Path]; ok { + continue + } + perm := matchPermission(r.Method, path) + if strings.HasSuffix(perm, ":view") { + t.Errorf("写接口 %s %s 挂在只读权限点 %q 上:只读角色会因此获得写能力。"+ + "请改挂对应的操作/完全档权限点;若该接口确实不改变任何状态,"+ + "把它加进 writeOnViewPermExceptions 并说明理由", r.Method, r.Path, perm) + } + } +} + // TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。 // // 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条 diff --git a/internal/service/admin.go b/internal/service/admin.go index 3c8473f..9cda7d6 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -417,8 +417,6 @@ type ( GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) // UpdateUserPermissions updates permission points for an admin user. UpdateUserPermissions(ctx context.Context, req *v1.AdminPermissionUpdateReq) (*v1.AdminPermissionUpdateRes, error) - // UpdateUserDataScopes updates data scopes for an admin user. - UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) // GetAllPermissions returns all predefined permission groups plus tier metadata. // // 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用): diff --git a/migrations/000020_admin_role_management.sql b/migrations/000020_admin_role_management.sql index 0edc5e5..8ae3b04 100644 --- a/migrations/000020_admin_role_management.sql +++ b/migrations/000020_admin_role_management.sql @@ -99,7 +99,7 @@ SELECT r.id, unnest(ARRAY[ 'channel:view','channel:create','channel:edit','channel:test','channel:delete', 'model:view','model:create','model:edit','model:delete', 'tenant:view','tenant:create','tenant:edit','tenant:suspend','tenant:delete','tenant:close', - 'member:view','member:import','member:model_scope', + 'member:view','member:import','member:manage','member:model_scope', 'plan:view','plan:create','plan:edit','plan:delete', 'billing:view','billing:export','billing:refund', 'order:view','order:refund', @@ -124,7 +124,7 @@ SELECT r.id, unnest(ARRAY[ 'channel:view','channel:create','channel:edit','channel:test','channel:delete', 'model:view','model:create','model:edit','model:delete', 'tenant:view','tenant:create','tenant:edit','tenant:suspend', - 'member:view','member:import','member:model_scope', + 'member:view','member:import','member:manage','member:model_scope', 'plan:view','plan:create','plan:edit','plan:delete', 'billing:view', 'order:view', diff --git a/migrations/000022_member_manage_permission.sql b/migrations/000022_member_manage_permission.sql new file mode 100644 index 0000000..57d5e08 --- /dev/null +++ b/migrations/000022_member_manage_permission.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- 新增 member:manage 权限点:管理后台的成员禁用/启用/重置密码/解锁。 +-- +-- 此前这四个写接口挂在只读的 member:view 上(internal/middleware/rbac.go), +-- 于是「技术支持」这类只读定位的角色可以重置任意租户成员的密码 —— 改密等于接管该成员 +-- 账号,进而可用其 API Key 消耗租户余额。档位与动作不匹配,属于授权配置缺陷。 +-- +-- 存量补授规则:给【已持有 member:import 的角色】补 member:manage。 +-- · member:import 本就在 member 模块的「操作」档,持有它的角色已经能创建成员, +-- 补上「管理成员」不扩大其实际能力边界,行为保持连续; +-- · 只读档(仅 member:view,如预置的 support 角色)不补 —— 这正是本次要修的越权; +-- · 同时保证「操作/完全」档的角色在界面上仍然能反推出档位,不会退化成「自定义」。 +-- +-- 幂等:ON CONFLICT DO NOTHING,可重复执行;新装部署由 000020 的种子直接带上该权限点。 + +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT DISTINCT rp.role_id, 'member:manage' +FROM sys_admin_role_permissions rp +WHERE rp.permission_point = 'member:import' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 用户特批权限同理:特批过 member:import 的账号一并补 member:manage, +-- 否则依赖特批权限工作的账号会在升级后突然失去成员管理能力。 +INSERT INTO sys_admin_role_perms (admin_user_id, permission_point) +SELECT DISTINCT p.admin_user_id, 'member:manage' +FROM sys_admin_role_perms p +WHERE p.permission_point = 'member:import' +ON CONFLICT DO NOTHING; + +-- +goose Down +-- 回滚:移除该权限点。回滚后 rbac.go 若仍要求 member:manage,相关接口对非超管一律 403, +-- 因此代码与迁移需要同步回退。 +DELETE FROM sys_admin_role_permissions WHERE permission_point = 'member:manage'; +DELETE FROM sys_admin_role_perms WHERE permission_point = 'member:manage'; diff --git a/web/admin/src/views/tenant/MembersPage.vue b/web/admin/src/views/tenant/MembersPage.vue index be2e98c..aa88362 100644 --- a/web/admin/src/views/tenant/MembersPage.vue +++ b/web/admin/src/views/tenant/MembersPage.vue @@ -8,6 +8,7 @@ import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' import request from '@/utils/request' import { useExport } from '@/composables/useExport' +import { hasPermission } from '@/utils/permission' import ResponsiveTable from '@/components/ResponsiveTable.vue' const loading = ref(false) @@ -174,6 +175,11 @@ const columns: TableColumnData[] = [ width: 180, fixed: 'right', render({ record }) { + // 禁用/启用/改密/解锁四个动作在后端要求 member:manage(只读的 member:view + // 只能看列表)。无权限时不渲染按钮,避免点了才拿到 403。 + if (!hasPermission('member:manage')) { + return h('span', { style: 'color: var(--color-text-3)' }, '—') + } const isDisabled = record.status === 'disabled' const locked = !!record.locked_until && new Date(record.locked_until) > new Date() const actions = [ diff --git a/web/admin/src/views/tenant/detail/WalletTab.vue b/web/admin/src/views/tenant/detail/WalletTab.vue index 8d03888..4cbdd6d 100644 --- a/web/admin/src/views/tenant/detail/WalletTab.vue +++ b/web/admin/src/views/tenant/detail/WalletTab.vue @@ -262,9 +262,10 @@ watch(() => props.active, (v) => { if (v) refreshAll() })