fix(permissions):修复后端权限漏洞

This commit is contained in:
chengqian committed 2026-09-03 15:05:31 +08:00
1 parent 9d61abda86
commit e703c9a044
21 files changed
+233 -136

No files matched your search

+4 -2
View File
@@ -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作为仓库贡献者提交
-1
View File
@@ -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)
+6 -13
View File
@@ -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 {
+25
View File
@@ -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 的两条规则。
@@ -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)
}
+9 -2
View File
@@ -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,
+6 -57
View File
@@ -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
+3 -2
View File
@@ -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 定义档位由低到高的顺序,用于「权限点集合 → 档位」的反向推导。
+2 -2
View File
@@ -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",
+8
View File
@@ -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
}
+7
View File
@@ -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
}
+23
View File
@@ -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
+7
View File
@@ -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 {
+7 -3
View File
@@ -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)
+44 -44
View File
@@ -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.
+36
View File
@@ -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 保证角色管理的每个端点都被限定为超级管理员专属。
//
// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条
-2
View File
@@ -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.
//
// 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用):
+2 -2
View File
@@ -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',
@@ -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';
@@ -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 = [
@@ -262,9 +262,10 @@ watch(() => props.active, (v) => { if (v) refreshAll() })
<ACard :bordered="false" title="钱包信息" class="mb-4">
<template #extra>
<ASpace :wrap="isMobile" :size="isMobile ? 'small' : 'medium'">
<AButton type="primary" @click="openRecharge">调整余额</AButton>
<AButton status="success" @click="openOfflineRecharge">线下入账</AButton>
<AButton @click="openThreshold">预警设置</AButton>
<!-- 三个动作在后端都要求 billing:refund(billing:view 只能看余额),按权限点渲染 -->
<AButton v-if="hasPermission('billing:refund')" type="primary" @click="openRecharge">调整余额</AButton>
<AButton v-if="hasPermission('billing:refund')" status="success" @click="openOfflineRecharge">线下入账</AButton>
<AButton v-if="hasPermission('billing:refund')" @click="openThreshold">预警设置</AButton>
</ASpace>
</template>
<ADescriptions :column="isMobile ? 1 : 3" bordered size="medium">