|
@@ -0,0 +1,268 @@
|
|
|
|
|
+package auth
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "errors"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "perms-system-server/internal/consts"
|
|
|
|
|
+ "perms-system-server/internal/loaders"
|
|
|
|
|
+ deptModel "perms-system-server/internal/model/dept"
|
|
|
|
|
+ memberModel "perms-system-server/internal/model/productmember"
|
|
|
|
|
+ userModel "perms-system-server/internal/model/user"
|
|
|
|
|
+ "perms-system-server/internal/response"
|
|
|
|
|
+ "perms-system-server/internal/testutil/ctxhelper"
|
|
|
|
|
+ "perms-system-server/internal/testutil/mocks"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/stretchr/testify/assert"
|
|
|
|
|
+ "github.com/stretchr/testify/require"
|
|
|
|
|
+ "github.com/zeromicro/go-zero/core/stores/sqlx"
|
|
|
|
|
+ "go.uber.org/mock/gomock"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// 覆盖目标:审计 H-2(第 8 轮)—— checkPermLevel 必须对 caller.MinPermsLevel 做 DB fresh read。
|
|
|
|
|
+//
|
|
|
|
|
+// 修复前:caller.MinPermsLevel 来自 UserDetailsLoader 5min TTL 缓存。超管刚把 caller 降级后
|
|
|
|
|
+// 缓存里仍是旧(更高权)级别,降级 admin 在 5min 内仍可跨级管辖目标用户 —— 这是 M-3 的
|
|
|
|
|
+// GuardRoleLevelAssignable 修复所没有覆盖的"直接管人"分支。
|
|
|
|
|
+//
|
|
|
|
|
+// 修复后契约:
|
|
|
|
|
+// 1. caller 刚被降级,缓存 MinPermsLevel=低(高权)但 DB 实值=高(低权)→ 走 DB 值,仍拒 403。
|
|
|
|
|
+// 2. caller 在 DB 里已无角色(sqlx.ErrNotFound)→ callerNoRole=true → 同级管辖拒。
|
|
|
|
|
+// 3. caller 侧 DB 抖动(非 ErrNotFound)→ 500 fail-close,不得降级为"无角色放行"。
|
|
|
|
|
+// 4. SuperAdmin caller 短路,永不触发 caller 侧 DB 读。
|
|
|
|
|
+// 5. caller 管自己时短路在 CheckManageAccess 顶部,根本不到 checkPermLevel。
|
|
|
|
|
+//
|
|
|
|
|
+// 命名规则:TC-0969 ~ TC-0975
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+const h2TestProductCode = "pc_h2"
|
|
|
|
|
+
|
|
|
|
|
+// h2MockTargetMemberAndDept 为所有 H-2 测试共享的 target 侧 mock:
|
|
|
|
|
+// - target.MemberType=MEMBER(与 MEMBER caller 同级 → 必然进入 permsLevel 对比路径)
|
|
|
|
|
+// - target.DeptPath=/100/101/ 使 caller.DeptPath=/100/ 顺利覆盖
|
|
|
|
|
+func h2MockTargetMemberAndDept(ctrl *gomock.Controller) (
|
|
|
|
|
+ *mocks.MockSysUserModel,
|
|
|
|
|
+ *mocks.MockSysDeptModel,
|
|
|
|
|
+ *mocks.MockSysProductMemberModel,
|
|
|
|
|
+) {
|
|
|
|
|
+ userMock := mocks.NewMockSysUserModel(ctrl)
|
|
|
|
|
+ userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
|
|
|
|
|
+ Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).AnyTimes()
|
|
|
|
|
+ deptMock := mocks.NewMockSysDeptModel(ctrl)
|
|
|
|
|
+ deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
|
|
|
|
|
+ Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil).AnyTimes()
|
|
|
|
|
+ pmMock := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
|
|
+ pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), h2TestProductCode, int64(42)).
|
|
|
|
|
+ Return(&memberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil).AnyTimes()
|
|
|
|
|
+ return userMock, deptMock, pmMock
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func h2DowngradedCallerCtx(cachedLevel int64) *loaders.UserDetails {
|
|
|
|
|
+ return &loaders.UserDetails{
|
|
|
|
|
+ UserId: 1,
|
|
|
|
|
+ Username: "h2_caller",
|
|
|
|
|
+ IsSuperAdmin: false,
|
|
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
|
|
+ Status: consts.StatusEnabled,
|
|
|
|
|
+ ProductCode: h2TestProductCode,
|
|
|
|
|
+ DeptId: 100,
|
|
|
|
|
+ DeptPath: "/100/",
|
|
|
|
|
+ MinPermsLevel: cachedLevel,
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0969: caller 缓存里还是高权(level=10),但 DB 已被降级到低权(level=100)
|
|
|
|
|
+// target level=50;按缓存 "10 < 50" 应放行,按 DB fresh read "100 >= 50" 应拒绝。
|
|
|
|
|
+// 修复后必须以 DB 为准 → 403。
|
|
|
|
|
+func TestCheckPermLevel_StaleCacheHighPriv_FreshReadLowPriv_Forbids(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
|
|
+
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+ // target fresh read:level=50
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
|
|
+ // caller fresh read:DB 真实已降级到 100(低权),比 target 的 50 更低权 → 应拒
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
|
|
+ Return(int64(100), nil).Times(1)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
|
|
+ })
|
|
|
|
|
+ // 关键:缓存里还是 10(降级前的高权级别)—— 源码如果信任 caller.MinPermsLevel 就会放行。
|
|
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
|
|
+
|
|
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
|
|
+ require.Error(t, err, "H-2:降级后缓存仍高权的 caller 必须被 DB 实值拦截")
|
|
|
|
|
+ var ce *response.CodeError
|
|
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
|
|
+ assert.Equal(t, 403, ce.Code(),
|
|
|
|
|
+ "H-2:TOCTOU 修复后必须走 DB,结果为 403;若测试看到 200/nil err 说明仍读的是 caller.MinPermsLevel 缓存")
|
|
|
|
|
+ assert.Contains(t, ce.Error(), "无权管理权限级别高于或等于您的用户")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0970: caller 在 DB 里已无任何角色(ErrNotFound)→ 即便缓存仍显示 MinPermsLevel=10,也必须拒。
|
|
|
|
|
+// 对称验证 M-3 里 GuardRoleLevelAssignable 对"caller 被清角色"的处理方式。
|
|
|
|
|
+func TestCheckPermLevel_CallerFreshRead_NotFound_ForbidsEvenWithCachedLevel(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
|
|
+
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).Times(1)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
|
|
+ })
|
|
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
|
|
+
|
|
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
|
|
+ require.Error(t, err, "H-2:caller 在 DB 已无角色时一律拒绝同级/跨级管辖")
|
|
|
|
|
+ var ce *response.CodeError
|
|
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
|
|
+ assert.Equal(t, 403, ce.Code())
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0971: 正向通过路径 —— caller DB level=10(高权),target level=50 → 严格高权,放行。
|
|
|
|
|
+// 用来证明修复没有误伤合法管理路径。
|
|
|
|
|
+func TestCheckPermLevel_FreshRead_HigherPriv_Passes(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
|
|
+ Return(int64(10), nil).Times(1)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
|
|
+ })
|
|
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
|
|
+
|
|
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
|
|
+ assert.NoError(t, err, "H-2:合法严格高权管理路径不得被修复误伤")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0972: caller 侧 DB 非 ErrNotFound 错误 → 500 fail-close。
|
|
|
|
|
+// 对称于 checkPermLevel 里 target 侧的 L-4 fail-close,把 caller 侧也钉死,避免"DB 抖动被伪装成无角色"。
|
|
|
|
|
+func TestCheckPermLevel_CallerFreshRead_GenericDBErr_FailsClosedWith500(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
|
|
+ // caller 侧 DB 抖动
|
|
|
|
|
+ dbErr := errors.New("driver: bad connection")
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
|
|
+ Return(int64(0), dbErr).Times(1)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
|
|
+ })
|
|
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
|
|
+
|
|
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
|
|
+ require.Error(t, err)
|
|
|
|
|
+ var ce *response.CodeError
|
|
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
|
|
+ assert.Equal(t, 500, ce.Code(),
|
|
|
|
|
+ "H-2:caller 侧 DB 抖动必须 fail-close 500,绝不能伪装成'无角色→放行'")
|
|
|
|
|
+ // 不得透传驱动细节
|
|
|
|
|
+ assert.NotContains(t, ce.Error(), "driver: bad connection")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0973: SuperAdmin caller 短路 —— 不应触发任何 caller 侧 DB 读。
|
|
|
|
|
+// 如果修复把 fresh read 放错位置,SuperAdmin 也会被多打一次 DB,测试会因 Role mock 收到未预期调用而挂。
|
|
|
|
|
+func TestCheckPermLevel_SuperAdmin_ShortCircuits_NoCallerFreshRead(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ // 故意不设任何 mock EXPECT —— 任何 DB 调用都会 fail。
|
|
|
|
|
+ userMock := mocks.NewMockSysUserModel(ctrl)
|
|
|
|
|
+ deptMock := mocks.NewMockSysDeptModel(ctrl)
|
|
|
|
|
+ pmMock := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ super := &loaders.UserDetails{
|
|
|
|
|
+ UserId: 999, Username: "super_h2", IsSuperAdmin: true,
|
|
|
|
|
+ MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled,
|
|
|
|
|
+ ProductCode: h2TestProductCode,
|
|
|
|
|
+ }
|
|
|
|
|
+ err := CheckManageAccess(ctxhelper.CustomCtx(super), svcCtx, 42, h2TestProductCode)
|
|
|
|
|
+ assert.NoError(t, err, "H-2:SuperAdmin 必须在 CheckManageAccess 顶部短路,不得触发 fresh read")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0974: caller 管理自己时必须在 CheckManageAccess 顶部短路(L-7 已钉);
|
|
|
|
|
+// H-2 修复不得把这条路径带偏到 fresh read。
|
|
|
|
|
+func TestCheckPermLevel_ManageSelf_ShortCircuits_NoFreshRead(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ // 故意不设 mock EXPECT,caller 管自己应该一次 DB 都不打。
|
|
|
|
|
+ userMock := mocks.NewMockSysUserModel(ctrl)
|
|
|
|
|
+ deptMock := mocks.NewMockSysDeptModel(ctrl)
|
|
|
|
|
+ pmMock := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(100))
|
|
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 1 /* 就是 caller.UserId */, h2TestProductCode)
|
|
|
|
|
+ assert.NoError(t, err, "H-2/L-7:caller 管自己永远放行,且不触发 DB fresh read")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TC-0975: H-2 与 M-3 共享 loadFreshMinPermsLevel helper(契约自洽性)。
|
|
|
|
|
+// 既然 checkPermLevel 与 GuardRoleLevelAssignable 两个授权决策点的 caller 侧都走同一个 helper,
|
|
|
|
|
+// 任意通用 DB 错误都必须映射为同一文案的 500 CodeError,任意 ErrNotFound 都映射为 notFound=true。
|
|
|
|
|
+// 这里覆盖 helper 的两个对称分支。
|
|
|
|
|
+func TestLoadFreshMinPermsLevel_ContractParity(t *testing.T) {
|
|
|
|
|
+ t.Run("generic DB error → 500 fail-close", func(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(7), h2TestProductCode).
|
|
|
|
|
+ Return(int64(0), errors.New("i/o timeout")).Times(1)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: roleMock})
|
|
|
|
|
+ level, notFound, err := loadFreshMinPermsLevel(ctxhelper.CustomCtx(nil), svcCtx, 7, h2TestProductCode)
|
|
|
|
|
+ assert.Equal(t, int64(0), level)
|
|
|
|
|
+ assert.False(t, notFound)
|
|
|
|
|
+ require.Error(t, err)
|
|
|
|
|
+ var ce *response.CodeError
|
|
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
|
|
+ assert.Equal(t, 500, ce.Code())
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ t.Run("ErrNotFound → notFound=true, err=nil", func(t *testing.T) {
|
|
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
|
|
+
|
|
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(8), h2TestProductCode).
|
|
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).Times(1)
|
|
|
|
|
+
|
|
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: roleMock})
|
|
|
|
|
+ level, notFound, err := loadFreshMinPermsLevel(ctxhelper.CustomCtx(nil), svcCtx, 8, h2TestProductCode)
|
|
|
|
|
+ assert.NoError(t, err)
|
|
|
|
|
+ assert.True(t, notFound)
|
|
|
|
|
+ assert.Equal(t, int64(0), level)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|