Browse Source

feat: 超管修改用户权限时必须指定产品编码

BaiLuoYan 22 hours ago
parent
commit
b8d0164d98

+ 2 - 0
README.md

@@ -332,6 +332,7 @@ POST /api/user/bindRoles  {"userId": 11, "roleIds": [2], "productCode": "crm"}
 # 额外给李四"导出报表"权限,但禁止他"创建订单"
 POST /api/user/setPerms  {
   "userId": 11,
+  "productCode": "crm",
   "perms": [
     {"permId": 7, "effect": "ALLOW"},
     {"permId": 6, "effect": "DENY"}
@@ -1464,6 +1465,7 @@ Content-Type: application/json
 | 字段 | 类型 | 必填 | 说明 |
 | ------ | ------ | ------ | ------ |
 | userId | int64 | 是 | 用户 ID |
+| productCode | string | 超管必填 | 产品编码。超级管理员必须显式传入;非超管从 JWT 上下文自动取,传入此字段无效 |
 | perms | array | 是 | 权限覆盖列表(全量替换,空数组清除所有覆盖) |
 | perms[].permId | int64 | 是 | 权限 ID |
 | perms[].effect | string | 是 | `ALLOW`(额外授予)或 `DENY`(强制拒绝) |

+ 10 - 0
internal/logic/user/setUserPermsLogic.go

@@ -41,7 +41,17 @@ func (l *SetUserPermsLogic) SetUserPerms(req *types.SetPermsReq) error {
 	// 再 RequireProductAdminFor"的顺序会让仅持有 JWT 的普通 MEMBER 通过 404/成功两种响应
 	// 枚举产品内的 userId 存在性,是一条被动信息泄露面。productCode 来自 middleware(权威),
 	// 提升零成本,把枚举面一刀切在鉴权之后。
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
 	productCode := middleware.GetProductCode(l.ctx)
+	if caller.IsSuperAdmin {
+		if req.ProductCode == "" {
+			return response.ErrBadRequest("必须指定产品编码")
+		}
+		productCode = req.ProductCode
+	}
 	if err := authHelper.RequireProductAdminFor(l.ctx, productCode); err != nil {
 		return err
 	}

+ 83 - 21
internal/logic/user/setUserPermsLogic_test.go

@@ -76,7 +76,8 @@ func TestSetUserPerms_Allow(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: p1, Effect: "ALLOW"},
 			{PermId: p2, Effect: "ALLOW"},
@@ -112,7 +113,8 @@ func TestSetUserPerms_Deny(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: p1, Effect: "DENY"},
 		},
@@ -132,7 +134,8 @@ func TestSetUserPerms_UserNotFound(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: 999999999,
+		UserId:      999999999,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: 1, Effect: "ALLOW"},
 		},
@@ -166,7 +169,8 @@ func TestSetUserPerms_EmptyPerms_ClearsAll(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: p1, Effect: "ALLOW"},
 		},
@@ -174,8 +178,9 @@ func TestSetUserPerms_EmptyPerms_ClearsAll(t *testing.T) {
 	require.NoError(t, err)
 
 	err = logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
-		Perms:  []types.UserPermItem{},
+		UserId:      userId,
+		ProductCode: "test_product",
+		Perms:       []types.UserPermItem{},
 	})
 	require.NoError(t, err)
 
@@ -199,7 +204,8 @@ func TestSetUserPerms_InvalidEffect(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: 1, Effect: "INVALID"},
 		},
@@ -228,7 +234,8 @@ func TestSetUserPerms_PermNotExists(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: 999999999, Effect: "ALLOW"},
 		},
@@ -261,7 +268,8 @@ func TestSetUserPerms_PermBelongsToOtherProduct(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: otherPerm, Effect: "ALLOW"},
 		},
@@ -295,7 +303,8 @@ func TestSetUserPerms_ConflictingEffects(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: p1, Effect: "ALLOW"},
 			{PermId: p1, Effect: "DENY"},
@@ -330,7 +339,8 @@ func TestSetUserPerms_DuplicatePermDedup(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: p1, Effect: "ALLOW"},
 			{PermId: p1, Effect: "ALLOW"},
@@ -374,7 +384,8 @@ func TestSetUserPerms_DisabledPermRejected(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err = logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: disabledPermId, Effect: "ALLOW"},
 		},
@@ -399,8 +410,9 @@ func TestSetUserPerms_NonMemberRejected(t *testing.T) {
 
 	logic := NewSetUserPermsLogic(ctx, svcCtx)
 	err := logic.SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
-		Perms:  []types.UserPermItem{},
+		UserId:      userId,
+		ProductCode: "test_product",
+		Perms:       []types.UserPermItem{},
 	})
 	require.Error(t, err)
 
@@ -464,8 +476,9 @@ func TestSetUserPerms_L4_TOCTOU_CountMismatch_RollsBackWith409(t *testing.T) {
 	}
 
 	err = NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
-		Perms:  []types.UserPermItem{{PermId: disabledPermId, Effect: "ALLOW"}},
+		UserId:      userId,
+		ProductCode: "test_product",
+		Perms:       []types.UserPermItem{{PermId: disabledPermId, Effect: "ALLOW"}},
 	})
 
 	require.Error(t, err, "前置通过但 DB 实际 Disabled 时,事务末 COUNT 必须触发 409")
@@ -506,7 +519,8 @@ func TestSetUserPerms_L4_AllEnabled_CountPasses(t *testing.T) {
 	})
 
 	err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
+		UserId:      userId,
+		ProductCode: "test_product",
 		Perms: []types.UserPermItem{
 			{PermId: p1, Effect: "ALLOW"},
 			{PermId: p2, Effect: "DENY"},
@@ -770,8 +784,9 @@ func TestSetUserPerms_L_R13_2_DenyTypeFlipRollsBack(t *testing.T) {
 	}
 
 	err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
-		Perms:  []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectDeny}},
+		UserId:      userId,
+		ProductCode: "test_product",
+		Perms:       []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectDeny}},
 	})
 	require.Error(t, err, "事务内读到 ADMIN 必须拒绝写 DENY")
 	var ce *response.CodeError
@@ -816,8 +831,9 @@ func TestSetUserPerms_L_R13_2_AllowOnlySkipsShareLock(t *testing.T) {
 	}
 
 	err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
-		UserId: userId,
-		Perms:  []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectAllow}},
+		UserId:      userId,
+		ProductCode: "test_product",
+		Perms:       []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectAllow}},
 	})
 	require.NoError(t, err,
 		"纯 ALLOW 请求 hasDeny==false,必须短路、不调 FindOneForShareTx;"+
@@ -827,3 +843,49 @@ func TestSetUserPerms_L_R13_2_AllowOnlySkipsShareLock(t *testing.T) {
 	require.Len(t, rows, 1)
 	assert.Equal(t, "ALLOW", rows[0].Effect)
 }
+
+// TC-1302: 超级管理员不传 ProductCode 时必须返回 400。
+func TestSetUserPerms_SuperAdmin_MissingProductCode(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+
+	err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
+		UserId: 999999999,
+		Perms:  []types.UserPermItem{},
+	})
+	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-1303: 超级管理员传入 ProductCode 时正常工作。
+func TestSetUserPerms_SuperAdmin_WithProductCode(t *testing.T) {
+	ctx := ctxhelper.SuperAdminCtx()
+	svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
+	conn := testutil.GetTestSqlConn()
+
+	username := testutil.UniqueId()
+	userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass"))
+	mId := insertTestMember(t, svcCtx, "test_product", userId)
+	permId := insertTestPerm(t, svcCtx, "test_product")
+
+	t.Cleanup(func() {
+		testutil.CleanTableByField(ctx, conn, "`sys_user_perm`", "userId", userId)
+		testutil.CleanTable(ctx, conn, "`sys_product_member`", mId)
+		testutil.CleanTable(ctx, conn, "`sys_user`", userId)
+		testutil.CleanTable(ctx, conn, "`sys_perm`", permId)
+	})
+
+	err := NewSetUserPermsLogic(ctx, svcCtx).SetUserPerms(&types.SetPermsReq{
+		UserId:      userId,
+		ProductCode: "test_product",
+		Perms:       []types.UserPermItem{{PermId: permId, Effect: consts.PermEffectAllow}},
+	})
+	require.NoError(t, err, "超级管理员传入 ProductCode 时应正常设置权限")
+
+	rows := findUserPerms(t, ctx, userId)
+	require.Len(t, rows, 1)
+	assert.Equal(t, consts.PermEffectAllow, rows[0].Effect)
+}

+ 3 - 2
internal/types/types.go

@@ -272,8 +272,9 @@ type RoleListReq struct {
 }
 
 type SetPermsReq struct {
-	UserId int64          `json:"userId"`
-	Perms  []UserPermItem `json:"perms"`
+	UserId      int64          `json:"userId"`
+	ProductCode string         `json:"productCode,optional"`
+	Perms       []UserPermItem `json:"perms"`
 }
 
 type SyncPermItem struct {

+ 3 - 2
perm.api

@@ -330,8 +330,9 @@ type (
 		Perms []UserPermItem `json:"perms"`
 	}
 	SetPermsReq {
-		UserId int64          `json:"userId"`
-		Perms  []UserPermItem `json:"perms"`
+		UserId      int64          `json:"userId"`
+		ProductCode string         `json:"productCode,optional"`
+		Perms       []UserPermItem `json:"perms"`
 	}
 	UpdateUserStatusReq {
 		Id     int64 `json:"id"`

+ 2 - 0
test-design.md

@@ -591,6 +591,8 @@ MySQL (InnoDB) + Redis Cache
 | TC-1104 | POST /api/user/setPerms | 非 ADMIN caller + **不存在**的 userId 必须 403(而不是 404)以消除 userId 枚举 oracle | `MemberCtx` + `UserId=999999999` | `CodeError.Code()==403`,文案含 "仅超级管理员或该产品的管理员";DB `sys_user_perm` 无写入 | 安全/枚举 | P0 | 反回归:`RequireProductAdminFor` 必须先于 `SysUserModel.FindOne(userId)` |
 | TC-1105 | POST /api/user/setPerms | DENY TOCTOU:预检读 member=MEMBER 通过,事务内 S 锁快照返回 ADMIN → 400 并回滚 | `FindOneByProductCodeUserId → MEMBER`;装饰 `FindOneForShareTx → ADMIN` 返回 | `CodeError.Code()==400`,文案含 "产品管理员或开发者";`sys_user_perm` 无脏 DENY 行 | 对抗/一致性 | P0 | 若 L-R13-2 事务内复核被拆除,脏 DENY 行会落盘("能写永不生效") |
 | TC-1106 | POST /api/user/setPerms | ALLOW-only 请求 **不得** 走 `FindOneForShareTx` S 锁路径(避免把热路径退化到锁链) | `Perms=[{PermId, ALLOW}]`;装饰 member model 断言 `FindOneForShareTx` 调用数=0 | 正常落盘 1 行 ALLOW;mock 上 `FindOneForShareTx` 未被调用 | 契约/性能 | P1 | 防把 S 锁挂到全量路径导致并发降级 |
+| TC-1302 | POST /api/user/setPerms | 超级管理员不传 `productCode` 时必须 400 | `SuperAdminCtx` + `productCode=""` | `CodeError.Code()==400`,文案含 "必须指定产品编码" | 参数校验 | P0 | 超管不再从 middleware 取 productCode,必须显式传入 |
+| TC-1303 | POST /api/user/setPerms | 超级管理员传入 `productCode` 时正常设置权限 | `SuperAdminCtx` + `productCode="test_product"` + 有效 permId | code=0,DB 落盘 1 行 ALLOW | 正向回归 | P0 | 守护超管显式传 productCode 的正常路径 |
 | TC-1164 | POST /api/user/detail | 产品 ADMIN 看同产品他人:返回完整 Email/Phone/Remark | `AdminCtx(P1)`;target 是 P1 MEMBER,带完整 Email/Phone/Remark | 响应中 `Email/Phone/Remark` 与 DB 原值严格相等 | 正向回归 | P0 | ADMIN 是 PII 最小授权白名单之一;守护默认脱敏上线后不伤 ADMIN 视角 |
 | TC-1165 | POST /api/user/detail | 产品 DEVELOPER 看同产品他人:返回完整 Email/Phone/Remark | `DeveloperCtx(P1)`;target 是 P1 MEMBER | 响应中 `Email/Phone/Remark` 与 DB 原值严格相等 | 正向回归 | P0 | DEVELOPER 被纳入 PII 白名单;与 ADMIN 同口径 |
 | TC-1166 | POST /api/user/detail | 产品 MEMBER 看同产品他人:Email/Phone/Remark 必须为空字符串 | `MemberCtx(P1)`;target 是 P1 的其他成员 | `Email==""`、`Phone==""`、`Remark==""`;nickname/deptId 等非 PII 字段仍然返回;DB 原值未被改动 | 安全/最小授权 | P0 | 普通 MEMBER 视角不得窥视他人 PII;避免 PII 明文外泄 |

+ 7 - 5
test-report.md

@@ -1,6 +1,6 @@
 # 权限管理系统 (perms-system-server) — 测试报告
 
-> 报告日期: 2026-05-15(新增用户凭证票据机制 CreateUser/ResetPassword/FetchUserCredentials
+> 报告日期: 2026-05-19(SetUserPerms 超管必须显式传 productCode
 > 测试范围: REST API (go-zero) + gRPC + Model 层 (自定义方法 + _gen.go 模板生成) + Logic 单元测试 + util 层 + 访问控制 + UserDetailsLoader + 中间件
 > 测试用例设计详见 [test-design.md](./test-design.md)
 > 执行命令: `go test -count=1 -timeout 600s ./...`
@@ -12,10 +12,10 @@
 | 指标 | 数值 |
 | :--- | :--- |
 | 测试包总数 | **28** |
-| TC 用例总数 (test-design.md) | **1065** |
-| 顶层测试函数数 (Functions) | **1170** |
-| 测试执行事件总数 (含 `t.Run` 子用例) | **1309** |
-| ✅ 通过 | **1307** |
+| TC 用例总数 (test-design.md) | **1067** |
+| 顶层测试函数数 (Functions) | **1172** |
+| 测试执行事件总数 (含 `t.Run` 子用例) | **1311** |
+| ✅ 通过 | **1309** |
 | ⏭️ 跳过 | **2** |
 | ❌ 失败 | **0**(本轮全绿) |
 | 通过率 (TC 维度) | **100%**(扣除 2 条不可达防御分支 Skip) |
@@ -620,6 +620,8 @@
 | TC-1104 | SetUserPerms 非 ADMIN caller + 不存在 userId → 403(阻断 userId 枚举) | ✅ pass |
 | TC-1105 | SetUserPerms DENY TOCTOU:事务内读到 ADMIN → 400 + 事务回滚(无 DENY 脏行) | ✅ pass |
 | TC-1106 | SetUserPerms 纯 ALLOW 必须短路、不调 FindOneForShareTx(S 锁开销不扩散) | ✅ pass |
+| TC-1302 | SetUserPerms 超级管理员不传 productCode → 400 "必须指定产品编码" | ✅ pass |
+| TC-1303 | SetUserPerms 超级管理员传入 productCode → 正常设置权限,DB 落盘 1 行 ALLOW | ✅ pass |
 | TC-1127 | L-R14-2:BindRoles 跨产品 / 已禁用 / 不存在三路径文案必须统一为"包含无效的角色ID" | ✅ pass |
 | TC-1164 | 产品 ADMIN 看同产品他人 UserDetail:返回完整 Email/Phone/Remark | ✅ pass |
 | TC-1165 | 产品 DEVELOPER 看同产品他人 UserDetail:返回完整 Email/Phone/Remark | ✅ pass |