Bläddra i källkod

feat: 调整 getUserPerms 的逻辑

BaiLuoYan 21 timmar sedan
förälder
incheckning
c2b74ffbdc

+ 5 - 5
README.md

@@ -1472,7 +1472,7 @@ Content-Type: application/json
 
 #### POST /api/user/userPerms — 查看用户权限覆盖
 
-查询指定用户在当前产品下已配置的个性化 ALLOW/DENY 覆盖记录。供管理后台在打开「设置权限」抽屉时回填当前状态,使编辑页能正确展示现有的授权/拒绝项。
+查询指定用户在产品下已配置的个性化 ALLOW/DENY 覆盖记录。供管理后台在打开「设置权限」抽屉时回填当前状态,使编辑页能正确展示现有的授权/拒绝项。
 
 **调用场景:**
 
@@ -1481,14 +1481,14 @@ Content-Type: application/json
 
 **访问控制:**
 
-- 用户查询自己的覆盖记录:无需管理员权限,直接允许
-- 非超管查询他人记录:需先通过产品管理员身份检查(`RequireProductAdminFor`),再通过 `CheckManageAccess` 校验是否有权管理目标用户,防止普通成员枚举其他用户 ID
+- 必须是产品管理员(`RequireProductAdminFor`)或超级管理员
+- 通过 `CheckManageAccess` 校验调用方权限等级高于目标用户,防止同级或低级管理员越权查询
+- 普通成员无法查询任何用户(包括自己)的覆盖记录,防止枚举 userId
 
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | userId | int64 | 是 | 目标用户 ID |
-
-> `productCode` 从 JWT 中间件注入的产品上下文获取,无需在请求体传入。
+| productCode | string | 超管必填 | 超级管理员必须显式传入;非超管从 JWT 上下文取,传入值被忽略 |
 
 **响应 data:** `{"perms": [{"permId": 1, "effect": "ALLOW"}, {"permId": 5, "effect": "DENY"}, ...]}`
 

+ 17 - 12
internal/logic/user/getUserPermsLogic.go

@@ -29,9 +29,11 @@ func NewGetUserPermsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetU
 	}
 }
 
-// GetUserPerms 查询用户在当前产品下的个性化权限覆盖项(仅 sys_user_perm 的 ALLOW/DENY 行,不含角色继承权限)。
-// 访问控制:超管不限;本人可查自己;其余调用者须是产品 ADMIN,且权限等级须高于目标用户。
-// 审计 L-R13-1:RequireProductAdminFor 在任何实体读取前调用,防止普通 MEMBER 通过响应差异枚举 userId 存在性。
+// GetUserPerms 查询指定用户在某产品下的个性化权限覆盖项(仅 sys_user_perm 的 ALLOW/DENY 行,不含角色继承权限)。
+// 适用场景:超管/产品 ADMIN 在管理后台查看权限等级低于自己、且与自己同产品的用户的权限覆盖配置。
+// 用户自身的最终有效权限在登录时由 UserDetailsLoader 计算并缓存,不走此接口。
+// 访问控制:须是产品 ADMIN 或超管,且通过 CheckManageAccess 校验权限等级高于目标用户。
+// 超管必须显式传入 productCode;非超管从 JWT 上下文取。
 func (l *GetUserPermsLogic) GetUserPerms(req *types.GetUserPermsReq) (resp *types.GetUserPermsResp, err error) {
 	caller := middleware.GetUserDetails(l.ctx)
 	if caller == nil {
@@ -39,16 +41,19 @@ func (l *GetUserPermsLogic) GetUserPerms(req *types.GetUserPermsReq) (resp *type
 	}
 
 	productCode := middleware.GetProductCode(l.ctx)
-
-	isSelf := caller.UserId == req.UserId
-	if !caller.IsSuperAdmin && !isSelf {
-		// 审计 L-R13-1:先鉴权再读实体,避免枚举 userId。
-		if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
-			return nil, err
-		}
-		if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
-			return nil, err
+	if caller.IsSuperAdmin {
+		if req.ProductCode == "" {
+			return nil, response.ErrBadRequest("必须指定产品编码")
 		}
+		productCode = req.ProductCode
+	}
+
+	// 审计 L-R13-1:先鉴权再读实体,避免枚举 userId。
+	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
+		return nil, err
+	}
+	if err := authHelper.CheckManageAccess(l.ctx, l.svcCtx, req.UserId, productCode); err != nil {
+		return nil, err
 	}
 
 	rows, err := l.svcCtx.SysUserPermModel.FindByUserIdForProduct(l.ctx, req.UserId, productCode)

+ 11 - 35
internal/logic/user/getUserPermsLogic_test.go

@@ -9,7 +9,6 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"perms-system-server/internal/consts"
-	"perms-system-server/internal/loaders"
 	memberModel "perms-system-server/internal/model/productmember"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
@@ -46,7 +45,7 @@ func insertUserPerm(t *testing.T, svcCtx *svc.ServiceContext, userId, permId int
 	require.NoError(t, err)
 }
 
-// TC-1257: 超管查任意用户权限覆盖
+// TC-1257: 超管查任意用户权限覆盖(必须传 productCode)
 func TestGetUserPerms_SuperAdmin(t *testing.T) {
 	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
@@ -66,7 +65,7 @@ func TestGetUserPerms_SuperAdmin(t *testing.T) {
 		testutil.CleanTable(ctx, conn, "`sys_perm`", permId)
 	})
 
-	resp, err := NewGetUserPermsLogic(ctx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId})
+	resp, err := NewGetUserPermsLogic(ctx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId, ProductCode: "test_product"})
 	require.NoError(t, err)
 	require.NotNil(t, resp)
 	require.Len(t, resp.Perms, 1)
@@ -74,40 +73,17 @@ func TestGetUserPerms_SuperAdmin(t *testing.T) {
 	assert.Equal(t, consts.PermEffectAllow, resp.Perms[0].Effect)
 }
 
-// TC-1258: 用户查询自己的权限覆盖(包含 ALLOW 和 DENY)
-func TestGetUserPerms_SelfQuery(t *testing.T) {
-	bootstrapCtx := ctxhelper.SuperAdminCtx()
+// TC-1304: 超管不传 productCode 时必须返回 400
+func TestGetUserPerms_SuperAdmin_MissingProductCode(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
 	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
-	conn := testutil.GetTestSqlConn()
-
-	username := testutil.UniqueId()
-	userId := insertTestUser(t, bootstrapCtx, username, testutil.HashPassword("pass"))
-	mId := insertTestMember(t, svcCtx, "test_product", userId)
-	permId1 := insertTestPerm(t, svcCtx, "test_product")
-	permId2 := insertTestPerm(t, svcCtx, "test_product")
-
-	insertUserPerm(t, svcCtx, userId, permId1, consts.PermEffectAllow)
-	insertUserPerm(t, svcCtx, userId, permId2, consts.PermEffectDeny)
-
-	t.Cleanup(func() {
-		testutil.CleanTableByField(bootstrapCtx, conn, "`sys_user_perm`", "userId", userId)
-		testutil.CleanTable(bootstrapCtx, conn, "`sys_product_member`", mId)
-		testutil.CleanTable(bootstrapCtx, conn, "`sys_user`", userId)
-		testutil.CleanTable(bootstrapCtx, conn, "`sys_perm`", permId1, permId2)
-	})
 
-	// caller == target(isSelf 分支,跳过 RequireProductAdminFor + CheckManageAccess)
-	selfCtx := ctxhelper.CustomCtx(&loaders.UserDetails{
-		UserId:      userId,
-		Username:    username,
-		MemberType:  consts.MemberTypeMember,
-		ProductCode: "test_product",
-		Status:      consts.StatusEnabled,
-	})
-	resp, err := NewGetUserPermsLogic(selfCtx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: userId})
-	require.NoError(t, err)
-	require.NotNil(t, resp)
-	assert.Len(t, resp.Perms, 2, "应返回 ALLOW 和 DENY 两条记录")
+	_, err := NewGetUserPermsLogic(ctx, svcCtx).GetUserPerms(&types.GetUserPermsReq{UserId: 1})
+	require.Error(t, err)
+	var ce *response.CodeError
+	require.True(t, errors.As(err, &ce))
+	assert.Equal(t, 400, ce.Code())
+	assert.Contains(t, ce.Error(), "必须指定产品编码")
 }
 
 // TC-1259: 产品 ADMIN 查同产品 MEMBER 的权限覆盖

+ 2 - 1
internal/types/types.go

@@ -142,7 +142,8 @@ type FetchUserCredentialsResp struct {
 }
 
 type GetUserPermsReq struct {
-	UserId int64 `json:"userId"`
+	UserId      int64  `json:"userId"`
+	ProductCode string `json:"productCode,optional"`
 }
 
 type GetUserPermsResp struct {

+ 2 - 1
perm.api

@@ -324,7 +324,8 @@ type (
 		Effect string `json:"effect"`
 	}
 	GetUserPermsReq {
-		UserId int64 `json:"userId"`
+		UserId      int64  `json:"userId"`
+		ProductCode string `json:"productCode,optional"`
 	}
 	GetUserPermsResp {
 		Perms []UserPermItem `json:"perms"`

+ 2 - 2
test-design.md

@@ -604,8 +604,8 @@ MySQL (InnoDB) + Redis Cache
 
 | TC编号 | 接口/方法 | 测试场景 | 输入参数 (JSON) | 预期结果 | 测试类型 | 优先级 | 覆盖说明 |
 | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
-| TC-1257 | POST /api/user/userPerms | 超管查任意用户权限覆盖 | SuperAdminCtx + `{"userId": targetId}`;目标用户有 1 条 ALLOW 记录 | code=0;`perms` 含该 ALLOW 项 | 正常路径 | P0 | SuperAdmin 不受产品限制;FindByUserIdForProduct 正向 |
-| TC-1258 | POST /api/user/userPerms | 用户查询自己的权限覆盖 | MemberCtx + `{"userId": self}`;自身有 ALLOW 和 DENY 各 1 条 | code=0;`perms` 含 2 项(ALLOW + DENY) | 正常路径/self | P0 | isSelf 分支,跳过 RequireProductAdminFor + CheckManageAccess |
+| TC-1257 | POST /api/user/userPerms | 超管查任意用户权限覆盖(必须传 productCode) | SuperAdminCtx + `{"userId": targetId, "productCode": "test_product"}`;目标用户有 1 条 ALLOW 记录 | code=0;`perms` 含该 ALLOW 项 | 正常路径 | P0 | SuperAdmin 必须显式传 productCode;FindByUserIdForProduct 正向 |
+| TC-1304 | POST /api/user/userPerms | 超管不传 productCode 时必须 400 | SuperAdminCtx + `{"userId": targetId}`(不传 productCode) | `CodeError.Code()==400`,文案含 "必须指定产品编码" | 参数校验 | P0 | 超管不再从 middleware 取 productCode,必须显式传入 |
 | TC-1259 | POST /api/user/userPerms | 产品 ADMIN 查同产品 MEMBER 的权限覆盖 | AdminCtx + `{"userId": memberId}` | code=0;`perms` 返回目标用户在当前产品下的覆盖项 | 正常路径 | P0 | RequireProductAdminFor 通过 + CheckManageAccess 等级检查通过 |
 | TC-1260 | POST /api/user/userPerms | 产品 ADMIN 查同级 ADMIN 被拒绝 | AdminCtx + `{"userId": otherAdminId}` | code=403 | 安全/越权 | P0 | CheckManageAccess 等级相等不允许 |
 | TC-1261 | POST /api/user/userPerms | 普通 MEMBER 查他人被拒绝 | MemberCtx + `{"userId": otherUserId}` | code=403;文案含 "仅超级管理员或该产品的管理员" | 安全/枚举防护 | P0 | RequireProductAdminFor 前置拦截,防止 MEMBER 枚举 userId |

+ 3 - 3
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-05-19(SetUserPerms 超管必须显式传 productCode)
+> 报告日期: 2026-05-19(GetUserPerms 移除自查豁免、超管必须显式传 productCode)
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -634,8 +634,8 @@
 
 | TC编号 | 测试场景 | 测试结果 |
 | :--- | :--- | :--- |
-| TC-1257 | 超管查任意用户权限覆盖 | ✅ pass |
-| TC-1258 | 用户查询自己的权限覆盖(ALLOW + DENY 各 1 条) | ✅ pass |
+| TC-1257 | 超管查任意用户权限覆盖(必须传 productCode) | ✅ pass |
+| TC-1304 | 超管不传 productCode 时必须 400 | ✅ pass |
 | TC-1259 | 产品 ADMIN 查同产品 MEMBER 的权限覆盖 | ✅ pass |
 | TC-1260 | 产品 ADMIN 查同级 ADMIN 被拒绝(等级校验) | ✅ pass |
 | TC-1261 | 普通 MEMBER 查他人被拒绝(RequireProductAdminFor 前置) | ✅ pass |