先说个场景:新系统开发到一半,产品经理丢过来一句“我们需要权限管理”。这句话我听了太多次,但每次落到.NET项目里,情况都不太一样。有的团队直接在Controller里写if (user.IsAdmin),有的花两周时间集成一个重型权限框架,最后被框架的配置和黑盒行为折腾到怀疑人生。
我自己在多个.NET项目里沉淀了一套RBAC权限系统设计,核心思路很简单:五张表、一个权限校验过滤器、一套权限码命名规范,复制到任何ASP.NET Core项目里都能跑。今天把这套东西完整拆开讲,从数据库建模到请求校验链路,从缓存设计到落地踩坑,全部摊开说。
这篇内容适合谁看?第一类是准备给内部管理系统做权限控制的后端开发,第二类是正在设计权限模块的架构师,第三类是手上有个老业务系统、想低成本接入RBAC的项目成员。往下看之前先给你交个底:这套设计不追求大而全,它解决的是“用户能不能访问某个功能”和“用户能不能看到某个按钮”这两个核心问题,数据行级权限和字段级权限在第7章单独讲扩展思路,别担心。
1. 模型选型:为什么我坚持用平级RBAC而不是角色继承
1.1 权限需求最常见的三种形态
做权限系统之前,先看看需求到底长什么样。据我观察,90%的业务系统权限需求逃不出三种形态:
- 菜单级权限:A角色登录后看到“订单管理”,B角色看不到,这是最基础的门禁控制。
- 操作级权限:用户能看到“用户管理”菜单,但“删除用户”按钮对他不可见,接口也不能调。
- 数据级权限:同样是查看订单,区域经理只能看本区域的,总部能看到全部,这已经超出RBAC的标准范畴。
前两种形态是RBAC的主场,也是这篇文章的核心。第三种形态很多人想用RBAC硬解,结果把权限表搞得无比复杂,最后还不一定对。我在第7章会讲在RBAC基础上怎么低成本扩展数据隔离,这里先按下不表。
第一次接触权限系统的人,特别容易在第三种需求上投入过多注意力,一上来就设计“数据范围字段、规则引擎、策略解析”,结果第一版就卡死在理论上。我建议先跑通RBAC,再考虑数据级,别把简单问题复杂化。
1.2 RBAC0和角色继承的取舍
RBAC模型其实分好几个版本:RBAC0是基础三元组(用户-角色-权限),RBAC1加了角色继承,RBAC2加了职责分离约束,RBAC3是两个都有。市面上很多教程一上来就把RBAC1的角色继承讲得很炫,比如“部门经理继承员工的权限,再额外加审核权”,听起来很符合组织架构。
但我在实际项目里吃过亏。做个集团权限的时候,发现一个“子公司管理员”要继承财务、人事、行政三个角色的部分权限,还要排除其中两个权限点,RBAC1的继承关系完全救不回来,最后只能回到平级角色,新建一个角色手工把权限点挨个勾选出来。
所以我现在的方案是:核心模型只用RBAC0,角色之间完全平级。权限配置人员看到的是“这个角色勾选了多少权限点”,而不是“这个角色从哪个角色继承了权限,又额外加了几个,再去掉了几个”。平级角色的逻辑清楚、审计方便、不会出现继承链断了导致权限神秘消失的问题。
1.3 这套方案的适用边界
再好的工具也有边界,这套RBAC设计适合以下场景:
- 后台管理系统、内部OA、运营平台,权限粒度到按钮级就够。
- 中小体量的SaaS产品,租户内部角色数量在几十个以内。
- 单体.NET应用或ASP.NET Core应用,权限校验逻辑集中在一个服务内。
下列情况我建议你先别直接用RBAC硬套:
- 跨系统的统一授权中心,那不是RBAC能解决的问题,要的是SSO加集中策略下发。
- 租户间权限规则差异极大的平台,可能需要独立的策略引擎。
- 强数据隔离场景,例如省经理只能看本省数据、客户经理只能看自己名下客户,这类需求必须在查询层做数据范围控制,不能只靠RBAC。
我见过一个团队把RBAC的角色表做成了组织架构树,把“部门”“岗位”“角色”混在一起,权限审核的时候根本分不清当前登录人到底因为哪个身份获得了权限。模块职责一旦混了,后面排查问题就变成侦探游戏。
2. 五张核心表的设计:权限点、角色、用户到底怎么建模
2.1 核心表结构与字段说明
RBAC的经典落地就是五张核心表,再加一张可选表。直接看结构:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| Users | 用户表 | Id、UserName、DisplayName、PasswordHash、Status |
| Roles | 角色表 | Id、Code、Name、Description、IsSystem |
| Permissions | 权限点表 | Id、Code、Name、ParentId、Type、Sort、Status |
| UserRoles | 用户角色关联表 | Id、UserId、RoleId |
| RolePermissions | 角色权限关联表 | Id、RoleId、PermissionId |
| UserPermissions(可选) | 用户直接授权表 | Id、UserId、PermissionId、IsGranted |
为什么权限点表统一叫Permissions,而不是分成菜单表、按钮表、接口表?因为前端渲染菜单、后端接口鉴权、按钮显隐判断,需要的都是同一份权限数据。我见过分表的方案,菜单一张表、按钮一张表、接口一张表,结果同步起来非常痛苦:前端菜单关联了按钮,按钮又关联接口,中间对不齐就出现“菜单能看见但接口调不通”的怪问题。统一成一张权限点表,用Type字段区分资源类型,所有模块都拿到同一把权限码去匹配,反而简单。
具体Type可以这样定义:
- 1:菜单,用于前端路由和菜单树渲染。
- 2:按钮,用于前端控制增删改查等操作入口。
- 3:API接口,用于后端过滤器鉴权。
还有一种扩展类型:4,代表数据范围。这是第7章讲数据级权限时需要的预留位。现在用不到也可以先留着,避免后面改动表结构。
关键字段有几个坑需要提前说明:
- Permissions.Code必须唯一,且一旦定义最好不要改。项目里的权限码会散落在代码的Attribute、前端的路由守卫、权限管理后台的配置里,改一个Code等于全网重构。
- Permissions.ParentId用来形成权限树。比如“用户管理”菜单的权限点是
system:user:view,它的子权限是“新增用户”“编辑用户”“删除用户”,通过ParentId挂进来。 - Status字段用来做软删除。权限点被角色关联表大量引用,硬删会牵连作用户角色数据,所以删除操作只更新状态,不删行。
另外,UserPermissions这张可选表建议默认建上,哪怕第一版用不到。实际业务经常出现“用户张三本来是普通员工,但这周要临时审核老板的报销单”,你不想为了一个临时权限去改整个角色的权限集合,直接用UserPermissions给张三单独加一个授权即可。表里的IsGranted字段还可以实现“某个角色有这个权限,但某个人被排除”,优先级高于角色权限。
2.2 权限码命名规范:把Code当身份证管起来
权限码是整套权限系统的“身份证”,命名规范必须在一开始定好。我用的是三段式:
{模块}:{功能}:{操作}示例:
system:user:viewsystem:user:createsystem:user:deleteorder:list:vieworder:list:audit
这里有几个约定要落地:
- 模块名是最顶层菜单的英文小写命名,一个系统里模块名不能重复。
- 操作动词固定用view、create、update、delete、export、import、audit。如果产品经理提了一个“转移负责人”的操作,不好意思,那基本等于update,别再造新动词。
- 按钮权限和接口权限用同一个Code,前端按钮判断一个字符串,后端Filter校验同一个字符串。比如删除用户按钮的Code是
system:user:delete,后端接口DeleteUser上也标注[Permission("system:user:delete")]。
为什么这么严格?因为权限点数量会随业务快速增长。小系统几十个权限点还能靠人脑记,做大了以后没有规范就会出现system_user_del、userDelete、delete_user这种混乱命名,管理后台里的权限树会变成灾难现场。我参与过的一个数据平台项目,就是因为前期没管Code命名,后面光做权限码的迁移就花了三个版本。
2.3 EF Core实体与关系配置
下面直接给可运行的实体定义。以.NET 6/7/8 + EF Core为例,我习惯用long做Id,理由是在团队协作中long型主键的扩展性比int好,后续要接缓存、消息队列也不会撞类型。
public class User { public long Id { get; set; } public string UserName { get; set; } public string DisplayName { get; set; } public string PasswordHash { get; set; } public int Status { get; set; } public DateTime CreatedAt { get; set; } public ICollection<UserRole> UserRoles { get; set; } } public class Role { public long Id { get; set; } public string Code { get; set; } public string Name { get; set; } public string Description { get; set; } public bool IsSystem { get; set; } public ICollection<UserRole> UserRoles { get; set; } public ICollection<RolePermission> RolePermissions { get; set; } } public class Permission { public long Id { get; set; } public string Code { get; set; } public string Name { get; set; } public long? ParentId { get; set; } public int Type { get; set; } public int Sort { get; set; } public int Status { get; set; } }关联实体:
public class UserRole { public long Id { get; set; } public long UserId { get; set; } public long RoleId { get; set; } public User User { get; set; } public Role Role { get; set; } } public class RolePermission { public long Id { get; set; } public long RoleId { get; set; } public long PermissionId { get; set; } public Role Role { get; set; } public Permission Permission { get; set; } }FluentAPI配置要点如下,重点是级联删除行为:
modelBuilder.Entity<UserRole>() .HasKey(ur => ur.Id); modelBuilder.Entity<UserRole>() .HasOne(ur => ur.User) .WithMany(u => u.UserRoles) .HasForeignKey(ur => ur.UserId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity<UserRole>() .HasOne(ur => ur.Role) .WithMany(r => r.UserRoles) .HasForeignKey(ur => ur.RoleId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity<RolePermission>() .HasKey(rp => rp.Id); modelBuilder.Entity<RolePermission>() .HasOne(rp => rp.Role) .WithMany(r => r.RolePermissions) .HasForeignKey(rp => rp.RoleId) .OnDelete(DeleteBehavior.Cascade);如果你不喜欢FluentAPI,也可以用数据注解,但关联实体的联合唯一约束(比如UserId + RoleId不能重复)还是建议在FluentAPI里配,数据注解表达不了。
实际配置里我还会给UserRoles加上联合唯一索引(UserId, RoleId),给RolePermissions加上(RoleId, PermissionId)。这一步很关键,否则权限管理后台重复提交表单会插入重复的关联记录,到时你排查“为什么权限越删越多”会卡很久。
3. 权限校验链路:从登录到请求放行,代码落在哪里
3.1 一次请求需要经过的权限判断
先画一下整条链路(用文字描述,不画图了,文字反而更清楚):
- 用户登录成功,系统根据UserId查出所有角色,再查出这些角色包含的全部权限Code集合,写入缓存,并把Code集合返回给前端。
- 前端拿到权限Code集合后,渲染菜单树、控制按钮显隐、做路由守卫。
- 用户点击某个按钮,前端发起API请求。
- 后端首先执行身份认证,确认用户是已登录状态。
- 权限过滤器读取当前Action上标注的权限Code,查一下用户缓存里的权限集合是否包含这个Code。
- 包含则放行,不包含返回403。
这条链路的重点在于:前端控制只是“体验优化”,真正的安全边界必须由后端把关。前端隐藏了删除按钮,不意味着用户不能手动发一个删除请求,后端权限过滤器必须拦得住。
3.2 自定义PermissionAttribute
在ASP.NET Core里做权限拦截,最直观的入口是自定义Attribute。先定义PermissionAttribute:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] public class PermissionAttribute : Attribute { public string Code { get; } public PermissionAttribute(string code) { Code = code; } }用法很简单,直接挂在Controller或Action上:
[HttpGet("users")] [Permission("system:user:view")] public IActionResult GetUsers() { return Ok(); } [HttpPost("users/{id}/delete")] [Permission("system:user:delete")] public IActionResult DeleteUser(long id) { return Ok(); }为什么选Attribute而不是在某个配置中心统一维护接口和权限码的映射?因为Attribute直接写在接口旁边,写代码的人和代码审查的人一眼就能看到“这个接口需要什么权限”。你在另一个配置文件里配映射,改接口时很容易忘记同步配置,而且配置文件的查询效率也不如反射元数据来得直观。
3.3 用IAsyncAuthorizationFilter统一拦截
接下来是关键代码。定义一个过滤器,在请求进入Controller之前完成权限校验:
public class PermissionAuthorizationFilter : IAsyncAuthorizationFilter { private readonly IPermissionChecker _checker; public PermissionAuthorizationFilter(IPermissionChecker checker) { _checker = checker; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { var endpoint = context.HttpContext.GetEndpoint(); var attribute = endpoint?.Metadata.GetMetadata<PermissionAttribute>(); if (attribute == null) { // 没有标注Permission的接口,不做权限拦截 return; } var userIdValue = context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrWhiteSpace(userIdValue)) { context.Result = new UnauthorizedResult(); return; } var hasPermission = await _checker.HasPermissionAsync(long.Parse(userIdValue), attribute.Code); if (!hasPermission) { context.Result = new ForbidResult(); } } }这段代码里有几个细节值得解释:
GetEndpoint()能拿到当前请求路由到的Action元数据,从里面读取Attribute,这套机制在Controller和Endpoint Routing模式下都有效。- 没标注
[Permission]的接口直接放行,这是一个强烈约定:所有接口要么标注权限码,要么显式标注[Authorize]声明它仅需登录即可访问。否则开发时忘标一个权限码,接口就悄无声息地裸奔了。 - 返回403用
ForbidResult(),返回未登录用UnauthorizedResult(),这个区分很重要,前端根据状态码决定是跳登录页还是提示无权限。
有人会问:为什么不用ASP.NET Core自带的IAuthorizationRequirement加AuthorizationHandler?我承认那套Policy机制很强大,适合复杂授权策略,但RBAC的校验本质就是“判断Code是否在权限集合里”,用Filter完全够用。写出来的代码短、直白,新人接手也能快速看懂。以后如果真要升级到ABAC,再在Filter里加入条件判断也不冲突。
3.4 IPermissionChecker的实现
上面代码依赖IPermissionChecker,这是权限校验的核心服务:
public class PermissionChecker : IPermissionChecker { private readonly IPermissionCache _cache; private readonly IUserRoleRepository _userRoleRepository; public PermissionChecker(IPermissionCache cache, IUserRoleRepository userRoleRepository) { _cache = cache; _userRoleRepository = userRoleRepository; } public async Task<bool> HasPermissionAsync(long userId, string permissionCode) { // 内置管理员角色拥有全部权限 if (await _userRoleRepository.IsInRoleAsync(userId, "admin")) { return true; } var codes = await _cache.GetPermissionCodesAsync(userId); return codes.Contains(permissionCode); } }注意这里把“超级管理员”的判定放在最前面,不走缓存也不走权限集合。下一节专门解释这么做的原因。
3.5 超级管理员为什么单独判断
很多人的第一反应是给超级管理员角色勾选所有权限点,让admin角色拥有全部权限。但这么做有一个隐藏风险:权限表新增一个权限点时,如果管理员忘了给admin角色同步勾选,超级管理员反而没有权限,新功能一上线管理员先打不开,还得排查半天。
改用代码单独判断是不是admin角色后,不管以后权限点加多少个,管理员始终拥有全部权限。这里补充一个保护规则:内置的admin角色不允许被删除,不允许取消IsSystem标记,管理后台至少要有一个不能动的角色兜底层级权限。
4. 挂进ASP.NET Core管道:模块化与接入老项目
4.1 推荐的项目分层
权限模块不要散落在业务代码里,建议单独划一个边界。我的习惯分层如下:
src/ ├── Acme.Core.Domain 实体、枚举、权限常量 ├── Acme.Core.EntityFrameworkCore DbContext、实体映射、迁移 ├── Acme.Application.Permission IPermissionService、IPermissionChecker ├── Acme.Infrastructure.Cache 缓存实现 └── Acme.Web 控制器、过滤器、Filter注册重点说下为什么要拆两个接口:
IPermissionService:负责权限配置,比如给角色分配权限、给用户分配角色、查询用户权限树。它走数据库,调用频率低。IPermissionChecker:负责运行时鉴权,判断某用户是否有某权限。它主要走缓存,调用频率极高,每个请求都可能触发。
拆开的好处是读写分离。写权限配置的代码不会混进高频校验路径,也方便单元测试:测PermissionChecker时只需要Mock缓存和角色仓库,不需要准备数据库。
4.2 DI注册与Filter挂载
在Program.cs里完成依赖注入和过滤器注册:
builder.Services.AddScoped<IPermissionService, PermissionService>(); builder.Services.AddScoped<IPermissionChecker, PermissionChecker>(); builder.Services.AddSingleton<IPermissionCache, PermissionCache>(); builder.Services.AddControllers(options => { options.Filters.Add<PermissionAuthorizationFilter>(); });过滤器注册的核心点是必须全局注册,不能只加到控制器或Action上。因为权限规则是全系统通用的安全策略,局部注册容易漏,一旦漏掉一个Action,那个接口就是裸奔的。
如果你的项目里权限过滤器需要依赖注入,options.Filters.Add<T>()这种泛型注册方式在.NET 6+是支持的,框架会从DI容器解析类型。如果用的还是老版本,可以考虑改成TypeFilterAttribute或手动在FilterDescriptors里装配,原理一样。
4.3 老项目接入的最小步骤
给已上线的老项目加RBAC,千万不要先想“一步到位重构”。我推荐一个低风险的接入步骤:
- 通过EF迁移在现有数据库里创建五张核心表。
- 写一个初始化脚本,内置admin和default两个角色,把需要权限控制的菜单和按钮录入Permissions表。
- 在登录接口的成功返回包里,追加一个权限Code集合字段。
- 全局注册
PermissionAuthorizationFilter。 - 逐个Controller去加
[Permission],从核心模块开始,不需要一天全改完。 - 前端根据权限Code集合,先接菜单渲染,再做按钮级
v-if。
这里有个容易被忽视的经验:全局注册过滤器后,没有加[Permission]的接口会直接放行,所以系统不会突然锁死,很多人会因此以为“权限加上去了”,实际上还没有。必须把“每个接口要么有权限码、要么明确是公共接口”作为Code Review的硬性检查项,持续推动5、6步落地。
5. 缓存策略:权限校验快得可感知,改权限却要秒级生效
5.1 为什么权限判断需要独立缓存层
如果不做缓存,每个API请求都会查一次用户角色关联表、再Join角色权限关联表。权限表小的时候无所谓,但系统用户上五千、权限点上三百之后,数据库的查询压力会明显反映在接口延迟上。
我实际测过的数据可以给你参考:一个数据集成项目里,权限校验不缓存时平均耗时20ms+,加缓存后平均不到1ms。20ms对单个接口不致命,问题是现在的页面都是接口聚合的,一个页面并行调五六个接口,权限校验就要占到一两百毫秒,体感卡顿就是这么攒出来的。
5.2 缓存结构设计与实现
单体部署直接用IMemoryCache,多实例部署建议用Redis,因为Redis可以跨实例共享缓存,权限变更后一台节点清缓存,其他节点也能感知(通过发布/订阅,或统一失效版本)。
缓存结构很简单:
Key: perm:user:{userId} Value: HashSet<string> // 权限Code集合用一个防并发的版本号辅助失效,是实现动态权限秒级生效的关键:
public class PermissionCache : IPermissionCache { private readonly IMemoryCache _cache; private readonly IPermissionRepository _repository; private static readonly Guid _version = Guid.NewGuid(); public async Task<ISet<string>> GetPermissionCodesAsync(long userId) { var key = $"perm:user:{userId}:v{_version}"; return await _cache.GetOrCreateAsync(key, async entry => { entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); return await _repository.GetPermissionCodesAsync(userId); }); } public void RefreshAll() { // 注意:这里不能直接改动上面的_version,因为它是static readonly // 生产环境建议把版本号存入Redis或MemoryCache的一个键 } }实际生产里,版本号要能动态更新。可以用一个ConcurrentDictionary保存“全局权限版本号”,每次权限变更时版本号自增,然后缓存Key里带上版本号。版本一变,所有旧Key全部失效,不需要维护“某个角色下有哪些用户”的映射关系。
5.3 权限变更后缓存如何失效
三种最常见的权限变更场景:
- 给角色分配或回收权限:影响该角色下所有用户,清掉这些用户的缓存。
- 给用户分配或回收角色:只影响该用户,清掉该用户缓存。
- 用户被禁用:清掉该用户缓存,并考虑在登录态层面阻止其后续请求。
用全局版本号方案后,不需要精确维护用户列表,一次全局版本变更就可以让所有用户缓存失效。代价是权限变更后的瞬时请求会集体回源数据库,如果系统有十万级用户,瞬间回源压力会比较大。更平滑的做法是“延迟双删”或“本地缓存加Redis发布订阅”,这些高级玩法可以等业务规模真到了再上,前期不要加复杂度。
5.4 必须避开缓存黑洞
这里说的黑洞,是指权限变更后,缓存长期刷新不出来的问题。我见过一个团队把用户权限缓存设置成“永不过期”,然后权限管理后台只改数据库。结果就是:角色权限改了,用户怎么刷新都还是旧权限,最后他们只能在发布站点的时候重启应用来清缓存。
我给一个实用建议:任何权限变更,都要在同一个服务方法里完成“改数据库 + 刷新缓存”,最好包在事务里。要么都成功,要么都失败,避免数据库和缓存不一致。再把全局版本号这个兜底机制加上,双保险之后基本不会出“改完权限半小时不生效”的问题。
6. 上线最容易翻车的六个权限细节
6.1 权限码散落成字符串,重构一改全崩
我见过很多项目里[Permission("system:user:delete")]的字符串写得到处都是,没有一个地方统一管理。等到权限码改名的时候,就得全文替换,漏一个就是一个静默漏洞,某个接口就突然变成任何人都能访问了。
解决办法是建一个静态权限常量类:
public static class Permissions { public const string UserView = "system:user:view"; public const string UserCreate = "system:user:create"; public const string UserUpdate = "system:user:update"; public const string UserDelete = "system:user:delete"; }所有代码引用常量,不再出现裸字符串。这个习惯成本极低,收益极高,强烈建议从第一天就执行。
6.2 前端v-if隐藏了按钮,后端却不校验
这是权限系统里最危险的翻车点之一。前端隐藏删除按钮只是“用户体验”,不代表请求不会被构造出来。只要后端接口没有权限校验,任何人随便用工具发一个删除请求就能删数据。
前端权限控制是让用户“看不到、点不到”,后端权限校验才是安全边界。这俩缺一不可,但后端的优先级永远高于前端。
6.3 超级管理员权限点没配全,登录后一脸懵
前面已经反复强调了,admin角色要用代码单独判断,不能依赖“勾选全部权限点”。如果你正处在从零搭建的阶段,建议直接把这条规则写进需求文档里,避免后面每次新增权限点都要给管理员角色补一遍全选。
6.4 权限变更后缓存和数据库不一致
改完角色权限,数据库是对的,但用户缓存里还是旧权限。这个现象在刚上线时特别容易遇到,因为很多团队只做了“改数据库”这一步,忘了“清缓存”。排查半小时,最后发现是缓存过期时间还没到。
我建议把权限变更和缓存失效封装到一个服务方法里,方法名就叫UpdateRolePermissionsAsync,内部逻辑是:改数据库 + 更新全局版本号。所有调用方只用这一个入口,不要允许任何人绕过服务直改关联表。
6.5 删除权限点不级联,角色关联表残留孤儿数据
删除一个父权限时,如果角色关联表里还残留着对子权限的引用,权限树里就会出现孤儿节点,管理后台的权限配置界面会非常乱。
删除权限的正确顺序是:先递归查出所有子权限,批量删除RolePermissions里的关联记录,再删除UserPermissions里的关联记录,最后把权限本身做软删除。不要图省事只执行一条DELETE FROM Permissions。
6.6 接口路径和权限码两套命名,排查问题全靠猜
有的项目里菜单表存的是system:user:delete,接口路径却写DeleteUser,甚至某个Controller改了个路由模板,从/system/user变成/user/manage,权限码还是原来的。两套命名体系一旦对不上,权限排查就要来回翻代码,效率极低。
约定:权限码是全系统的唯一标准。接口路径以后想改随便改,但权限码一旦定了就不要变。前端按钮、后端接口、菜单表里关联的都是同一个权限码。
7. RBAC之外:数据行级权限和字段级权限怎么做
7.1 数据行级权限的最小实现
RBAC解决的是“能不能访问这个资源”,数据权限解决的是“能访问资源的哪一部分数据”。最小可行的实现方案是:在用户表或用户扩展表上加数据范围字段,比如DepartmentId,然后在查询层自动拼接过滤条件。
例如订单列表接口,要求只能看本部门订单:
var departmentId = await _currentUser.GetDataScopeAsync(); var query = _context.Orders .Where(o => o.DepartmentId == departmentId) .ToList();关键原则:数据范围永远从后端登录态里解析,绝不信任前端传过来的部门ID。否则用户只要改一下请求参数,就能把整个集团的订单拉出来。
7.2 字段级权限:拆接口比动态序列化更省心
字段级权限常见于敏感信息场景,比如普通员工查看客户列表时不能看到身份证号、联系电话。处理方案有两种:
- 方案A:返回结果时动态序列化,按权限配置过滤字段。优点是前端接口少,缺点是配置复杂,过滤逻辑散落在序列化层,很容易漏字段。
- 方案B:拆成两个接口,
GetCustomerBasic返回基础字段,标注customer:view;GetCustomerDetail返回敏感字段,标注customer:detail。前端按权限码调不同接口。
我推荐方案B。它的代码可读性高,权限模型也不变,只是多定义几个权限码而已。做数据安全审计的时候,也能清楚看到谁调用了customer:detail接口,比动态序列化的黑盒行为靠谱得多。
7.3 什么时候才需要真正引入ABAC
当业务规则出现大量“请求上下文相关”的条件时,RBAC就不够用了。比如“工作时间内,普通财务可以审核;工作时间外,只有财务主管才能审核”“金额超过10万的订单,需要部门负责人二次确认”。这些本质上是策略判断,和用户角色、时间、金额、部门多个因素相关。
真正引入ABAC前,先考虑一个务实折中:以RBAC做主干的权限门禁,条件判断放进业务代码里。比如在订单审核业务逻辑里先调用权限过滤器确认基础权限,再判断订单金额和当前时间。这样既不用引入复杂的规则引擎,也能满足大部分条件授权需求。
我见过不少团队一上来就要设计ABAC规则引擎,结果业务需求没几个,光维护规则配置就消耗了大量人力。现实经验是:先跑通RBAC,遇到条件授权再局部扩展,别为了“架构先进”提前引入整套复杂度。
我个人实际走下来,这套RBAC设计最难的部分不是表和代码,而是权限码的规划。表结构随时可以改,权限码第一次没规划好,后面只能靠人肉治理。所以动手写代码之前,先花一个小时把模块拆清楚、操作动词定标准,这个时间花得绝对值。
最后再分享一个我自己常用的自查动作:每上线一个新接口,都习惯性地看一眼Controller的Action上有没有[Permission],是权限码不是裸字符串,然后顺手拿一个普通角色和一个admin角色各登录一遍去点一次。权限系统没有银弹,但这种习惯能帮你避开绝大多数生产事故。