diff --git a/XCode/Membership/DataScopeContext.cs b/XCode/Membership/DataScopeContext.cs index 54a831902..eb9043e9b 100644 --- a/XCode/Membership/DataScopeContext.cs +++ b/XCode/Membership/DataScopeContext.cs @@ -110,7 +110,8 @@ public class DataScopeContext // 全部权限不需要缓存 if (scope == DataScopes.全部) return null; - var key = $"DataScope:{userId}:{(Int32)scope}"; + // 缓存键包含部门编号,用户调岗(DepartmentID 变化)后立即使用新的部门列表,无需等待过期 + var key = $"DataScope:{userId}:{deptId}:{(Int32)scope}"; return _cache.GetOrAdd(key, k => DataScopeHelper.GetAccessibleDepartmentIds(deptId, roles, scope)); } @@ -120,10 +121,12 @@ public static void ClearCache(Int32 userId = 0) { if (userId > 0) { - // 清除该用户所有范围的缓存 - for (var i = 0; i <= 4; i++) + // 清除该用户所有缓存键(同时兼容旧键 DataScope:{userId}:{scope} 与新键 DataScope:{userId}:{deptId}:{scope}) + // 缓存失效为尽力而为:快照期间并发新增的键可能被漏删,但其值本身就是最新计算结果,漏删无害 + var prefix = $"DataScope:{userId}:"; + foreach (var key in _cache.Keys.ToArray()) { - _cache.Remove($"DataScope:{userId}:{i}"); + if (key.StartsWith(prefix)) _cache.Remove(key); } } else @@ -310,6 +313,8 @@ public static Int32[] ParseDepartmentIds(String? departmentIds) if (typeof(IUserScope).IsAssignableFrom(type)) { + // 纯用户实体(无部门列,如日志):非全部权限始终按当前用户过滤。 + // 无部门列时,本部门/自定义等范围不会 join 用户表放大成同事数据,避免越权可见。 var userField = GetUserField(factory); if (userField is not null) return userField.Equal(context.UserId); @@ -319,7 +324,7 @@ public static Int32[] ParseDepartmentIds(String? departmentIds) { var deptField = GetDepartmentField(factory); if (deptField is not null) - return BuildDepartmentFilter(context, deptField); + return BuildDepartmentScopeFilter(context, deptField); } return null; @@ -371,6 +376,24 @@ public static Int32[] ParseDepartmentIds(String? departmentIds) return deptField.In(deptIds); } + + /// 构建纯部门实体的过滤表达式 + /// + /// 适用于“一行一个部门”的表(如部门表,DepartmentId 映射到主键 ID)。 + /// 仅本人时没有可访问部门列表,退化为“当前用户所在部门”,即 ID=当前用户.DepartmentID, + /// 而不是恒假条件 Equal(-1) 导致空表。其余数据范围与普通部门过滤保持一致。 + /// + private static Expression? BuildDepartmentScopeFilter(DataScopeContext context, FieldItem? deptField) + { + if (deptField is null) return null; + + // 仅本人:部门表退化为“当前用户所在部门”,避免恒假条件导致空表 + if (context.DataScope == DataScopes.仅本人) + return deptField.Equal(context.DepartmentId); + + // 其余范围(本部门/本部门及下级/自定义)与普通部门过滤语义一致,空数组仍为恒假条件 + return BuildDepartmentFilter(context, deptField); + } #endregion #region 权限校验 @@ -431,6 +454,10 @@ public static Boolean CanAccess(IDepartmentScope entity, DataScopeContext? conte // 全部权限可访问所有数据 if (context.DataScope == DataScopes.全部) return true; + // 仅本人:部门表“一行一个部门”,退化为“当前用户所在部门” + if (context.DataScope == DataScopes.仅本人) + return entity.DepartmentId == context.DepartmentId; + var deptIds = context.AccessibleDepartmentIds; if (deptIds == null) return true; diff --git a/XCode/Membership/DataScopeModule.cs b/XCode/Membership/DataScopeModule.cs index bb303b2c5..5b9ce1af9 100644 --- a/XCode/Membership/DataScopeModule.cs +++ b/XCode/Membership/DataScopeModule.cs @@ -72,8 +72,10 @@ protected override Boolean OnValid(IEntity entity, DataMethod method) } catch (InvalidOperationException ex) { - // 失败时记录审计日志 + // 失败时记录审计日志,并拒绝该操作(返回 false),避免越权写入 LogProvider.Provider.WriteLog(entity.GetType(), method + "", false, ex.Message); + + return false; } return true; diff --git "a/XCode/Membership/\346\227\245\345\277\227.Biz.cs" "b/XCode/Membership/\346\227\245\345\277\227.Biz.cs" index 2d8c44ee2..d0decae65 100644 --- "a/XCode/Membership/\346\227\245\345\277\227.Biz.cs" +++ "b/XCode/Membership/\346\227\245\345\277\227.Biz.cs" @@ -5,7 +5,7 @@ namespace XCode.Membership; /// 日志 -public partial class Log : Entity +public partial class Log : Entity, IUserScope, IDataScopeFieldProvider { #region 对象操作 static Log() @@ -17,6 +17,7 @@ static Log() Meta.Interceptors.Add(); Meta.Interceptors.Add(); Meta.Interceptors.Add(); + Meta.Interceptors.Add(); #if !DEBUG // 关闭SQL日志 @@ -232,4 +233,15 @@ public static IList FindAllByCreateUserID(Int32 createUserId) /// public override String ToString() => $"{Category} {Action} {UserName} {CreateTime:yyyy-MM-dd HH:mm:ss} {Remark}"; #endregion + + #region IUserScope 成员 + // 日志表没有部门列,仅按创建用户过滤;本部门/自定义等范围不会 join 用户表放大成同事数据 + Int32 IUserScope.UserId { get => CreateUserID; set => CreateUserID = value; } + #endregion + + #region IDataScopeFieldProvider 成员 + XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetUserField() => _.CreateUserID; + XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetDepartmentField() => null; + XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetTenantField() => null; + #endregion } \ No newline at end of file diff --git "a/XCode/Membership/\347\224\250\346\210\267.Biz.cs" "b/XCode/Membership/\347\224\250\346\210\267.Biz.cs" index 7a52e8e0f..6aaced164 100644 --- "a/XCode/Membership/\347\224\250\346\210\267.Biz.cs" +++ "b/XCode/Membership/\347\224\250\346\210\267.Biz.cs" @@ -41,6 +41,7 @@ static User() Meta.Interceptors.Add(); Meta.Interceptors.Add(); Meta.Interceptors.Add(); + Meta.Interceptors.Add(); } /// 首次连接数据库时初始化数据,仅用于实体类重载,用户不应该调用该方法 diff --git "a/XCode/Membership/\350\217\234\345\215\225.Biz.cs" "b/XCode/Membership/\350\217\234\345\215\225.Biz.cs" index 6ae7aee90..f25f8259f 100644 --- "a/XCode/Membership/\350\217\234\345\215\225.Biz.cs" +++ "b/XCode/Membership/\350\217\234\345\215\225.Biz.cs" @@ -37,6 +37,11 @@ public override Boolean Valid(DataMethod method) if (Icon == "") Icon = null; + // 数据范围默认 -1(使用角色默认)。列默认值为 -1,但枚举默认是 0=全部, + // 新建或未显式配置的菜单若保存为 0 会覆盖角色默认为“全部”,故此处保持 -1。 + if (method == DataMethod.Insert && !IsDirty(__.DataScope) && (Int32)DataScope == 0) + DataScope = (DataScopes)(-1); + SavePermission(); return base.Valid(method); diff --git "a/XCode/Membership/\350\247\222\350\211\262.Biz.cs" "b/XCode/Membership/\350\247\222\350\211\262.Biz.cs" index 66c795d6e..0706b84bc 100644 --- "a/XCode/Membership/\350\247\222\350\211\262.Biz.cs" +++ "b/XCode/Membership/\350\247\222\350\211\262.Biz.cs" @@ -130,7 +130,9 @@ public override Boolean Valid(DataMethod method) Type = IsSystem ? RoleTypes.系统 : RoleTypes.普通; } - if (DataScope == 0) + // 仅在新增且未显式设置数据范围时,按角色类型填充默认值。 + // DataScopes.全部==0,更新或已显式赋值时,0 就表示“全部”,不再改写。 + if (method == DataMethod.Insert && !IsDirty(__.DataScope) && DataScope == 0) { DataScope = Type switch { diff --git "a/XCode/Membership/\351\203\250\351\227\250.Biz.cs" "b/XCode/Membership/\351\203\250\351\227\250.Biz.cs" index ece2b76f0..15a69d22b 100644 --- "a/XCode/Membership/\351\203\250\351\227\250.Biz.cs" +++ "b/XCode/Membership/\351\203\250\351\227\250.Biz.cs" @@ -9,7 +9,7 @@ namespace XCode.Membership; /// 部门。组织机构,多级树状结构 -public partial class Department : Entity, ITenantScope +public partial class Department : Entity, ITenantScope, IDepartmentScope, IDataScopeFieldProvider { #region 对象操作 private static Int32 MaxCacheCount = 10000; @@ -28,6 +28,7 @@ static Department() Meta.Interceptors.Add(); Meta.Interceptors.Add(); Meta.Interceptors.Add(); + Meta.Interceptors.Add(); } /// 验证并修补数据,返回验证结果,或者通过抛出异常的方式提示验证失败。 @@ -261,4 +262,14 @@ public static IList Search(Int32 tenantId, DepartmentTypes type, Int #region 业务操作 #endregion + + #region IDepartmentScope 成员 + Int32 IDepartmentScope.DepartmentId { get => ID; set => ID = value; } + #endregion + + #region IDataScopeFieldProvider 成员 + XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetUserField() => null; + XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetDepartmentField() => _.ID; + XCode.Configuration.FieldItem? IDataScopeFieldProvider.GetTenantField() => _.TenantId; + #endregion } \ No newline at end of file diff --git a/XUnitTest.XCode/Membership/DataScopeTests.cs b/XUnitTest.XCode/Membership/DataScopeTests.cs index 38ea8e3fe..cef98f1c9 100644 --- a/XUnitTest.XCode/Membership/DataScopeTests.cs +++ b/XUnitTest.XCode/Membership/DataScopeTests.cs @@ -477,7 +477,8 @@ public void DataScopeInterceptor_OnValid_Update_NoDirty_SkipsValidation() DataScope = DataScopes.仅本人 }; var entity = new DataScopeTestEntity { UserId = 999, DepartmentId = 888 }; - // 不修改任何属性,所以没有脏数据 + // 清除脏数据,模拟没有任何字段被修改的更新场景 + ((IEntity)entity).Dirtys.Clear(); // Act var result = module.Valid(entity, DataMethod.Update); @@ -1390,6 +1391,172 @@ public void DataScopes_EnumValues_Correct() Assert.Equal(4, (Int32)DataScopes.自定义); } #endregion + + #region 行权事实源验收测试 + [Fact] + [DisplayName("角色_普通角色全部_Update后仍为全部")] + public void Role_NormalRole_ScopeAll_Update_StaysAll() + { + // Arrange 普通角色显式使用“全部(0)” + var role = new Role { Name = "普通角色", Type = RoleTypes.普通, DataScope = DataScopes.全部 }; + + // Act 更新时不应把 0 改写成“本部门” + role.Valid(DataMethod.Update); + + // Assert + Assert.Equal(DataScopes.全部, role.DataScope); + } + + [Fact] + [DisplayName("角色_Insert未赋DataScope时填充Type默认值")] + public void Role_Insert_WithoutDataScope_FillsTypeDefault() + { + // Arrange 未显式赋值 DataScope + var role = new Role { Name = "业务角色", Type = RoleTypes.普通 }; + + // Act + role.Valid(DataMethod.Insert); + + // Assert 普通角色默认本部门 + Assert.Equal(DataScopes.本部门, role.DataScope); + } + + [Fact] + [DisplayName("拦截器_仅本人_拒绝修改他人IDataScope数据")] + public void Interceptor_SelfOnly_RejectUpdateOthersData() + { + // Arrange + var module = new DataScopeInterceptor(); + DataScopeContext.Current = new DataScopeContext + { + UserId = 100, + DepartmentId = 200, + DataScope = DataScopes.仅本人 + }; + var entity = new DataScopeTestEntity { UserId = 999, DepartmentId = 888 }; + entity.Name = "改动"; // 制造脏数据 + + // Act + var result = module.Valid(entity, DataMethod.Update); + + // Assert 校验失败不再放行 + Assert.False(result); + } + + [Fact] + [DisplayName("拦截器_本部门_拒绝在越权部门插入")] + public void Interceptor_Department_RejectInsertForeignDept() + { + // Arrange + var module = new DataScopeInterceptor(); + DataScopeContext.Current = new DataScopeContext + { + UserId = 100, + DepartmentId = 200, + DataScope = DataScopes.本部门, + AccessibleDepartmentIds = [200] + }; + var entity = new DepartmentScopeTestEntity { DepartmentId = 999 }; + + // Act + var result = module.Valid(entity, DataMethod.Insert); + + // Assert + Assert.False(result); + } + + [Fact] + [DisplayName("User_本部门滤DepartmentID_仅本人滤ID")] + public void User_Filter_Department_And_SelfOnly() + { + // 本部门:按部门过滤 + DataScopeContext.Current = new DataScopeContext + { + UserId = 100, + DepartmentId = 200, + DataScope = DataScopes.本部门, + AccessibleDepartmentIds = [200] + }; + var deptFilter = DataScopeHelper.GetFilter(); + Assert.NotNull(deptFilter); + var deptSql = deptFilter!.ToString(); + Assert.Contains("DepartmentID", deptSql); + Assert.Contains("200", deptSql); + + // 仅本人:按用户主键 ID 过滤 + DataScopeContext.Current = new DataScopeContext + { + UserId = 100, + DepartmentId = 200, + DataScope = DataScopes.仅本人 + }; + var selfFilter = DataScopeHelper.GetFilter(); + Assert.NotNull(selfFilter); + var selfSql = selfFilter!.ToString(); + Assert.DoesNotContain("DepartmentID", selfSql); + Assert.Contains("100", selfSql); + } + + [Fact] + [DisplayName("Log_本部门仍按CreateUserID过滤")] + public void Log_Filter_Department_UsesCreateUserID() + { + // 日志表无部门列,本部门范围仍按创建用户过滤,不放大成同事数据 + DataScopeContext.Current = new DataScopeContext + { + UserId = 100, + DepartmentId = 200, + DataScope = DataScopes.本部门, + AccessibleDepartmentIds = [200] + }; + var filter = DataScopeHelper.GetFilter(); + Assert.NotNull(filter); + var sql = filter!.ToString(); + Assert.Contains("CreateUserID", sql); + Assert.Contains("100", sql); + } + + [Fact] + [DisplayName("Department_仅本人滤当前部门ID而非-1")] + public void Department_Filter_SelfOnly_UsesCurrentDeptId() + { + // 部门表“一行一个部门”,仅本人退化为 ID=当前用户所在部门,而不是恒假条件 -1 + DataScopeContext.Current = new DataScopeContext + { + UserId = 100, + DepartmentId = 200, + DataScope = DataScopes.仅本人 + }; + var filter = DataScopeHelper.GetFilter(); + Assert.NotNull(filter); + var sql = filter!.ToString(); + Assert.Contains("200", sql); + Assert.DoesNotContain("-1", sql); + } + + [Fact] + [DisplayName("缓存_更换部门后可访问部门列表立即更新")] + public void Cache_DepartmentChange_UpdatesImmediately() + { + // Arrange + DataScopeContext.ClearCache(); + var user = new MockUser { ID = 100, DepartmentID = 200 }; + var role = new MockRole { ID = 1, IsSystem = false, DataScope = DataScopes.本部门 }; + user.Role = role; + user.Roles = [role]; + + // Act 初次创建 + var ctx1 = DataScopeContext.Create(user); + Assert.NotNull(ctx1); + Assert.Equal([200], ctx1!.AccessibleDepartmentIds); + + // 调岗后再次创建,缓存键含部门编号,立即生效 + user.DepartmentID = 300; + var ctx2 = DataScopeContext.Create(user); + Assert.NotNull(ctx2); + Assert.Equal([300], ctx2!.AccessibleDepartmentIds); + } + #endregion } #region 测试用实体类