生态产品多个设计器现已发布候选版本

查看生态总览
跳转到内容

数据权限 ​

权限码回答「能不能做这个操作」,数据权限回答「能对哪些行做」「能看到哪些字段」。两者是独立的维度——有权限码但查不到数据、能查到但某些列被打码,都是数据权限在起作用。

权限码本身见 权限管理。

两个维度 ​

text
一次请求要过的门:

  权限码        能不能调这个接口          → 不过:403
     ↓
  数据范围      能看到哪些行              → 不过:查不到(不是 403;目前只作用于用户列表)
     ↓
  字段级安全    能看哪些列、能改哪些列    → 不过:值被置空或被打码

「有权限但列表是空的」不是 bug

返回 200 且 items 为空、而不是 403——这是数据范围在起作用。排查方向是数据范围配置,不是权限码。

数据范围(行级) ​

范围枚举是 DataPermissionScope:角色的档位写在 SysRole.DataScope;成员的覆盖写在成员关系 SysTenantUser.DataScopeOverride(null 表示跟随角色),同一个人在不同租户各自设置。

范围含义依赖
SelfOnly仅本人数据—
DepartmentOnly本部门SysUserDepartment 的全部有效部门归属
DepartmentAndChildren本部门及下级部门闭包表
All全部数据(仍受租户过滤器约束)—
Custom指定部门集合Sys_Role_Data_Scope / Sys_User_Data_Scope

解析入口是 IUserDataScopeFilterService.ResolveAccessibleUsersAsync,当前只由用户分页查询(UserQueryService.GetUserPageAsync)调用,用户导出复用这条查询;用户详情、选择项,以及其它资源的查询和写操作都不施加数据范围。数据范围是租户侧概念:平台没有部门与成员关系,不施加数据范围(超管只在平台成立,也就不需要单独豁免)。

合并规则 ​

text
成员有覆盖            →  只按覆盖;Custom 时取 Sys_User_Data_Scope 里本成员的部门
        否则
启用角色的数据范围    →  多角色取并集;任一角色为 All 即放行全部;
                         Custom 角色并入 Sys_Role_Data_Scope 的部门
        ↓
一个部门都没命中      →  退回仅本人

成员覆盖用于「这个人比较特殊」的场景(如 CEO 挂的是部门经理角色、但要看全部),不要拿它当常规手段——角色才是可维护的授权单位。

档位与部门一次设置:角色在角色页的「数据范围」、成员在用户页的「数据范围」,先选档位,自定义时在部门树里勾部门、逐项设「含下级」。被上级「含下级」覆盖的部门会给出提示。全局角色只在平台设档位、不能自定义;支持成员(平台人员入驻)同样由所在租户设置。

依赖组织架构 ​

DepartmentOnly 与 DepartmentAndChildren 完全依赖 组织架构:

  • DepartmentOnly 取用户在当前租户下全部状态有效的部门归属(SysUserDepartment,不区分 IsMain);
  • DepartmentAndChildren 在此基础上靠部门闭包表一次展开整棵子树。

闭包表失真 = 数据范围算错

闭包表是查询加速镜像,所有写入必须由 SysDepartment 变更触发。绕过服务层直改部门表会让闭包表失真,表现是某些下级部门的数据看不到、或看到了本不该看到的。

修复要重建该子树的闭包,不要手工补行。

组织变更的连锁影响 ​

变更影响
移动部门闭包表重建 → 该子树下所有人的 DepartmentAndChildren 范围变化
改用户部门归属该用户的 DepartmentOnly / DepartmentAndChildren 范围变化
改角色数据范围该角色下所有用户的可见范围变化

数据范围每次请求实时从库解析,不进授权快照;角色写入路径会失效授权快照(InvalidateAuthorizationAsync),部门写入路径失效组织缓存(InvalidateOrganizationAsync)。

字段级安全(列级) ​

SysFieldLevelSecurity 按「实体 × 字段 × 目标」定义规则:限制某个角色 / 用户 / 部门对某个实体某个字段的读取方式与修改能力。规则只收紧、不放宽——没有规则就是明文可改。

字段说明
targetType / targetId目标:角色(当前用户生效且启用的角色)、用户(本人)、部门(该部门及其下级部门的有效成员;停用部门跳过但继续向上找)
entityName实体类名,如 SysUser;只能从实体目录里选
fieldName实体属性名,如 Phone(区分大小写);只能从该实体的字段里选
maskStrategy读取方式:明文、部分脱敏、哈希、全部星号、固定文本、隐藏
maskKeepHead / maskKeepTail部分脱敏保留前几位、后几位(仅部分脱敏,0–32,不能都为 0)
maskReplacement固定文本方式显示的文字(仅固定文本)
isEditable能否修改。明文规则必须只读(否则什么都没限制);脱敏且可编辑为「只写」:看不到原值,但能填新值覆盖(如更换密钥)

实体目录

可配置的实体由各模块在服务注册里登记(AddSaasFieldSecurityEntities、AddAIFieldSecurityEntities 等,底层是 FieldSecurityEntityOptions.Add<TEntity>())。实体显示名取表说明(SugarTable.TableDescription),字段显示名取列说明;主键、租户、并发版本、软删除等框架列不在字段列表里。配置页经 FieldLevelSecurityQuery/FieldSecurityEntities 取目录,实体与字段都是下拉选择,不手输。

登记即承诺:该实体的查询服务要门控查询条件,应用服务新建、修改(含状态)前要做写校验,响应里要能认出它的 DTO。FieldSecurityEntityWiringTests 从各模块的登记方法取登记表,核对这几处都已落地(门控与写校验扫源码,DTO 看映射器),缺一处就失败;代码里按未登记的实体调用字段安全,运行时直接报错。

非文本字段只能「明文只读」或「隐藏」(隐藏后返回类型默认值)。同一字段命中多条规则时取最严的读取方式,严格程度按泄露的信息量排:

text
明文 < 部分脱敏(泄露片段)< 哈希(泄露是否相等)< 全部星号(泄露长度)< 固定文本(只泄露有没有值)< 隐藏

部分脱敏多条时保留位数取最少;任一命中规则只读即只读。

平台规则(TenantId = 0)对所有租户生效,只能在平台维护;租户看得见、改不了(列表里标「平台」)。全局角色与系统角色的规则同样只能在平台维护。

脱敏在服务端完成 ​

后端返回的就是打码后的值

如 138****5678。前端不做二次打码,绕过前端直接调接口也拿不到明文。

打码在输出边界统一做,不逐个接口接线:FieldSecurityResponseFilter 在每个接口返回前沿响应的对象图(集合、分页、聚合详情里嵌套的 DTO、字典的值)找出属于登记实体的 DTO,按该实体的规则就地打码。列表、详情、下拉选项、聚合详情(如用户编辑表单取的 UserManagementDetail)、新建与修改后回传的详情都经过这一道口子,新接口不会因为漏接而漏出明文。

  • DTO 属于哪个实体:由应用层映射器认定——public static XxxDto ToXxx(SysEntity entity, …) 即认定 XxxDto 属于 SysEntity(IFieldSecurityDtoCatalog)。所以 DTO 必须经映射器从实体生成;一个 DTO 由两个登记实体映射出来时启动即报错。
  • 改名的属性:DTO 属性与实体字段不同名时(如 UserSessionId → SessionId),在 DTO 属性上加 [FieldSecuritySource(nameof(SysAccessLog.UserSessionId))],否则按名字打不到码;测试扫描映射器,未声明的改名直接失败。
  • 多来源 DTO:由多个实体映射出来的 DTO(如链路时间线 TraceTimelineItemDto)实现 IFieldSecurityMultiSourceDto,按每条自己的来源实体与字段对应打码。
  • 与响应缓存:过滤器排在框架缓存过滤器外层,缓存里存原值,每次命中按当次用户打码;HybridCache 对可变类型每次命中给新实例,就地打码不污染缓存。
  • 进程内直调不经过 HTTP 过滤器:导出任务按发起人身份调用查询服务,导出基类每取一页先 MaskAsync 再写出,导出文件与在线列表同一口径。进程内的业务逻辑直调查询服务拿到的是原值(需要真实值做计算)。

读全局数据要切到平台上下文的查询(打印模板、编号规则),查询门控放在切换之外:字段规则按请求方在所属租户的角色、部门判定;打码在响应过滤器里做,本就在请求方上下文。

查询条件也受门控 ​

构建完查询条件后调用 _fieldSecurity.GuardQueryAsync(request.Conditions, typeof(SysUser), ct),剔除读受保护字段(非明文)上的排序、过滤与关键字搜索字段——否则可按结果顺序或筛选命中反推原值。字段名大小写不敏感(前端列键多为 camelCase)。

强制约束放在门控之后

数据范围、超管隐藏、租户边界这类服务端强制条件,一律在 GuardQueryAsync 之后追加;否则有人把相关字段设成受保护,强制条件会被一起剔掉。

「排序点了没反应」优先查字段安全

被剔掉时后端不会报错,只是安静地不生效。「按某字段搜索没效果」也可能是这个原因。

写校验 ​

应用服务新建前调用 EnsureCreatableAsync(typeof(X), input, ct),修改与改状态前调用 EnsureUpdatableAsync(typeof(X), input.BasicId, input, ct):

  • 新建:只读字段不能填写(与入参类型默认值相同视为没填,空串与 null 同义)
  • 修改:只读字段改了值即拒绝;表单把拿到的脱敏值或空值原样交回视为没改,并还原成库里的原值——整体保存不会让 138****5678 覆盖真实号码
  • 只写字段:交回脱敏值视为没改,填了新值照常保存

没有只读或脱敏规则时不读库,零开销。同一请求内按「用户 + 实体」缓存解析结果,门控与打码共用一次查询。

个人中心改本人资料不经过字段安全(本人资料的读取也不打码)。

写路径的租户边界 ​

除了行级与列级,还有一条容易忽略的边界:读共享 ≠ 写共享。

text
读:全局过滤器放行 TenantId IN (0, 当前租户)   ← 租户能读到平台全局数据;平台只读 0
写:只能改写/删除当前作用域的行               ← 租户不能改全局行,平台也不能改租户行

实现方式:预读守卫校验取回行的 TenantId,条件写自动追加当前作用域的 Where。

平台就是 0 号租户:全局数据在平台作用域维护,某个租户的数据切入该租户(ICurrentTenant.Change(tenantId))维护;跨租户读取只能显式清过滤(...IgnoreTenantAsync / .ClearTenantFilter())。

账号域数据。账号、安全记录、个人设置、通知偏好、接口凭证、三方绑定、密码历史是严格隔离的账号域数据,TenantId 固定为注册地(平台账号为 0);一个人可以同时是多个租户的成员,切进别的租户后这些行经全局过滤不可见。个人中心的这些数据因此一律按 UserId 跨租户读(仓储上的 ...IgnoreTenantAsync 与按用户唯一键的读法);新增经 IAccountScope 切回注册地写,更新自己已有的行用 TenantWriteGuard.Suppress() 包裹(只放宽更新与删除,契约是只写当前用户自己的行)。会话与登录日志属于运行数据,按发生时所在的租户切分。

排查 ​

现象查什么
有权限但列表为空用户部门归属对不对 → 角色数据范围设成了什么 → 闭包表是否完整
某些行改不动是否跨租户写(读得到不等于写得动)
列变成 null / 空字段安全规则为「隐藏」,服务端已把值置空
列显示成 ***字段安全的脱敏方式生效,服务端已打码
排序 / 搜索不生效该字段读受保护,被剔出查询条件
改了数据范围不生效该列表是否接了数据范围过滤;成员是否有覆盖(有覆盖时角色的范围不再生效);角色是否停用、档位是否为自定义
配了规则不生效实体、字段在规则列表里标「已失效」(代码已移除);规则是否启用;目标是否命中(角色或部门停用、成员关系失效)
保存时报「无修改权限」当前用户对该字段只读,改动了它的值

相关页面 ​

Released under The MIT License