fix(permissions):修复后端权限漏洞
This commit is contained in:
1 parent
9d61abda86
commit
e703c9a044
21 files changed
+233
-136
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 定义档位由低到高的顺序,用于「权限点集合 → 档位」的反向推导。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 保证角色管理的每个端点都被限定为超级管理员专属。
|
||||
//
|
||||
// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
// 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用):
|
||||
|
||||
Reference in new issue
Block a user