做了大半年的NetCoreKevin微服务改造,到第12个核心模块AuthorizationService,才真正意识到授权这件事在整个架构里的分量。这套模块的定位很直接:在DDD分域、微服务拆分、WebApi网关后面,单独抽一个服务出来,统一回答“谁能在什么条件下访问什么资源”,并且要同时伺候AI智能体、AISK技能编排、MCP协议工具调用、SignalR长连接和Quartz定时任务这几类特殊调用方。这篇把AuthorizationService从领域建模到落地实现的完整过程整理出来,包括为什么没有走纯JWT权限声明,智能体动态提权怎么设计,MCP工具级授权怎么拦截,Quartz任务怎么把授权上下文带进Job,以及我在真实环境踩过的坑。适合正在做.NET微服务、准备接入Agent类AI能力,或者被长连接和定时任务权限问题困扰的同学参考。
1. AuthorizationService在整套架构里的定位
1.1 为什么必须单独拆一个授权服务,而不是继续在网关里写权限过滤
我接手NetCoreKevin这套系统的时候,权限逻辑已经散得到处都是。网关里有一层URL级别的粗粒度校验,每个业务服务自己又有一套角色判断,管理后台甚至有直接查数据库角色表的代码。最头疼的是AI智能体接入之后,原来的User-Role-Permission三层模型完全不够用:Agent不只是代表某个人,它经常要跨用户、跨租户地调工具,而且它在一次任务里可能动态申请临时权限,这已经超出了“登录后拿一份固定角色清单”的范畴。
所以我把认证和授权彻底拆开。认证是确认“你是谁”,这件事交给网关和统一登录中心;授权是确认“你能干什么”,这件事收拢到AuthorizationService。网关只校验JWT签名并提取主体标识,然后把这个主体标识通过内部Header转发给AuthorizationService,由它实时评估权限并返回授权结果。这样做的直接收益是:所有服务的权限判断逻辑只有一份代码,策略变更不用改服务、不用重新发布,网关只做转发,业务服务只认结果。
1.2 DDD战术设计落地:授权不是一堆if else,而是一个能演化的领域
AuthorizationService内部严格按DDD做了领域分层。最核心的聚合是授权策略(AuthorizationPolicy),它把权限点(PermissionPoint)、约束条件(Constraint)、有效期(EffectiveTime)组装成一个可以整体变更的聚合根。另一个重要聚合是主体上下文(PrincipalContext),它描述“谁”在请求,可能是人、角色、设备,也可能是AI智能体。领域服务是PermissionEvaluator,它负责加载策略、解析上下文、判断约束条件,最后返回授权决议。
为什么值得用DDD来写一个看似简单的授权模块?因为权限规则在真实业务里会持续演化。一开始只是“管理员能删用户”,后来变成“运营主管只能在华东区删用户且每日最多删500个”,再后来变成“Agent可以在获得用户确认的前提下以用户身份发送一条消息”。这些规则如果写成Service层里的if else,三个月后没人敢动。用领域模型把规则的骨架先落下来,每个新增规则都是对聚合和值对象的一次扩展,而不是对Controller的一次侵入。我强烈建议做微服务的团队把授权模块作为第一批DDD试点,它不是最复杂的业务,却是最容易失控的横切逻辑。
2. 授权模型的领域建模细节
2.1 六类主体统一建模:人的模型撑不住机器的世界
传统权限系统只关心用户和角色,但在NetCoreKevin这套架构里,调用方至少有六类:用户、角色、组织、设备、AI智能体、内部服务。它们都要访问资源,却有不同的授权语义。比如设备(Device)通常只有固定的几个API可调,AI智能体却要按任务动态获得工具权限,内部服务之间走的是服务账户身份。
我没有为每种主体单独建表,而是设计了一个统一的主体抽象:Principal表存主键、类型、名称、状态,另外按类型挂扩展表。这样做的原因是授权策略的评估逻辑只需要面向PrincipalType分支,而不用为每个类型写一套全新的授权链路。在代码里,PermissionEvaluator拿到的就是一个Principal对象加一串Attribute,比如Agent类型的Principal会带上ownerUserId、tenantId、sessionId这些属性,这些属性正是后续动态提权判断的关键数据。
| 主体类型 | 典型授权语义 | 关键扩展属性 |
|---|---|---|
| 用户 | 按角色/组织继承权限 | 部门、职级、区域 |
| 角色 | 权限模板,批量赋权 | 优先级、互斥规则 |
| 组织 | 数据范围控制 | 层级路径、区域编码 |
| 设备 | 固定API白名单 | 设备指纹、固件版本 |
| AI智能体 | 动态工具权限、用户委托 | ownerUserId、会话ID、任务ID |
| 内部服务 | 服务账户、接口级白名单 | 服务名、集群标识 |
2.2 权限点、策略与作用域的三层拆分
授权模型最底层的单位是权限点(PermissionPoint),它直接对应一项操作,比如order.cancel、customer.export、tool.sendMessage。权限点不包括任何业务条件,它只是“能不能做这件事”的开关。策略(Policy)把权限点和一组约束条件组合在一起,约束条件才是决定数据范围、频次限制、是否需要二次确认的地方。
举个例子,某个运营角色拥有customer.export权限点,但策略里约束条件是scope=华东区且每日导出量<=10000条。这里的作用域(Scope)被建模成值对象,由区域、部门、项目等维度组合而成。评估时PermissionEvaluator会先判断权限点是否命中,再计算约束条件里的表达式,最后才返回PermissionDecision。三层拆分的意义在于复用:同一个权限点可以挂在多个策略下,每个策略有完全不同的约束,新增一个策略不会污染其他调用方。
public class AuthorizationPolicy : AggregateRoot { public string PolicyCode { get; private set; } public PermissionPoint PermissionPoint { get; private set; } public IReadOnlyList<IConstraintRule> Constraints { get; private set; } public DateTime EffectiveStart { get; private set; } public DateTime EffectiveEnd { get; private set; } public PermissionDecision Evaluate(PrincipalContext context) { if (DateTime.UtcNow < EffectiveStart || DateTime.UtcNow > EffectiveEnd) return PermissionDecision.Deny("policy expired"); foreach (var constraint in Constraints) { var result = constraint.IsSatisfiedBy(context); if (!result.IsSatisfied) return PermissionDecision.Deny(result.Reason); } return PermissionDecision.Allow(); } }2.3 权限变更通过领域事件传播,而不是让下游服务反向查询
分布式系统里最忌讳的做法是:权限变化了,然后通知所有服务“你重新查我一遍”。AuthorizationService的做法是发布领域事件,比如PolicyChangedEvent、PrincipalSuspendedEvent、AgentDelegationRevokedEvent,这些事件进消息总线,各微服务用自己的订阅逻辑更新本地缓存或者清理连接。
这个设计的核心价值是最终一致性。权限服务不保证下游在100毫秒内感知变化,但通过事件版本号机制保证它们最终会拿到最新策略。我遇到过团队质疑:既然有Redis共享缓存,为什么不直接让下游服务每次读Redis?因为本地缓存加事件的组合比每次远程访问Redis的延迟低一个数量级,而且不会因为Redis抖动导致全链路超时。事件总线下游消费失败时会有重试,重试几次还失败就进死信队列,由运维告警兜底。
3. 核心授权流程:从登录到请求再到智能体动态提权
3.1 网关校验身份,授权服务给出权限决议
一次典型请求的链路是这样的:客户端带着JWT访问网关,网关验签后把x-principal-id、x-principal-type、x-tenant-id这几个内部Header转发给下游服务。下游服务不是自己解析JWT里的角色,而是调用AuthorizationService的授权接口,传入资源标识和动作标识,拿到一个授权决议。决议里包含allowed布尔值、有效期、数据作用域、额外约束信息。
这里有一个容易踩的坑:不要在业务服务里把授权结果自己缓存太长时间。我有一次把授权结果在服务本地缓存了30分钟,结果管理后台给用户解禁之后,用户那边还是403,排查了半天才发现是缓存。现在统一的做法是:AuthorizationService返回的授权决议自带cacheTtlSeconds字段,允许下游做短缓存,默认300秒;涉及敏感操作(退款、删库、发消息)的权限点强制cacheTtlSeconds=0,每次都实时评估,这个逻辑是配置在权限点上的,不需要业务服务配合。
3.2 AI智能体通过AISK技能编排层申请临时工具权限
AI智能体部分是本系统改造中最特殊的一块。智能体本身没有固定角色,它每执行一个任务,可能需要调多个工具。如果按传统方式给它分配一堆静态权限,一是风险太大,二是根本预测不到它在某个会话里会调什么。我们的做法是把AISK(AI Skill Kit)作为技能编排层夹在智能体和业务工具之间:智能体在思考过程中向AISK请求“我想调用发送消息工具”,AISK拿到请求后去AuthorizationService做一次动态授权。
动态授权请求里带的关键字段是ownerUserId(智能体代表谁)、taskId、sessionId、requestedPermissionPoint。授权服务会检查智能体是否有基础调用权,同时校验用户是否曾经给这个会话授予过该工具的委托权。如果智能体要调用的工具超出用户已确认的范围,授权服务返回pending_user_confirmation状态,AISK会把这个状态转成一次前端确认卡片,用户点同意后才发放临时权限。这个机制把“AI代操作”的风险控制在了用户可感知的粒度上。
3.3 为什么最终没有选择纯JWT携带权限清单的方案
早期架构确实考虑过在JWT里直接塞权限声明列表,一了百了。但AI场景先把这个方案否决了:智能体的一次会话可能持续一个多小时,它中途会不断获得新的工具权限,使用纯JWT意味着要么客户端频繁刷新Token,要么智能体拿着一个不包含最新权限的旧Token到处碰壁。第二个否决理由是权限回收太慢。JWT只要没过期就不能被服务端主动吊销,要等它自然过期,这在高风险操作上是不可接受的。
所以最终方案是JWT只负责身份认证,授权结果完全由AuthorizationService实时下发。内部服务之间传递的Token是我自己封装的一个短时内部凭证,TTL只有5分钟,加上每次请求都带traceId,审计链路非常清晰。代价是授权服务不能出故障,所以它的部署架构是3节点起,Redis和数据库都做了主从,这些成本换来了权限的实时性,我觉得非常值。
4. SignalR实时通道里的授权与连接生命周期管理
4.1 连接态Token与请求态Token分开,长连接不背过期锅
SignalR最典型的坑是:长连接在握手时校验了JWT,但一个连接可能存活几个小时,中间JWT过期了,服务端不好主动踢,客户端又因为鉴权失败而静默掉线。NetCoreKevin里我做了两层Token分离。第一层是连接Token,只在握手时校验,负责确认身份和建立连接映射;第二层是请求态Token,每条业务指令(比如实时推送、在线编辑保存)都要通过IHttpContextAccessor链路去AuthorizationService做一次独立授权。
这个设计在物联网设备场景里尤其重要。设备连接建立之后可能三天不断线,如果还按旧方案,第三天发指令时权限早就变了。现在指令级的实时授权能立刻感知到设备被禁用、用户被移出组织、Agent被回收委托等变化,安全性比单纯的连接握手校验高一个等级。SignalR的Hub基类里我封装了一个EnsurePermissionAsync(permissionCode)方法,业务代码一行调用,背后的实时授权对上层完全透明。
4.2 权限变更后主动断开或降级已建立的长连接
长连接场景另一个躲不开的问题是权限变更怎么推送到已经在线的客户端。用户角色被降级后,他可能还挂在SignalR集群的某个节点上,旧方案里他本人不刷新页面就永远不会感知到。AuthorizationService发布PrincipalSuspendedEvent之后,SignalR服务订阅这个事件,通过Redis Pub/Sub把“权限变更”消息广播到集群的所有节点,每个节点用连接Id找到对应的连接,调用Clients.Client(connectionId).SendAsync("permissionChanged", newPolicyVersion)。
比较激进的做法是直接强制断开连接,让客户端完全重连来做一次全新的握手和授权。但在生产环境我建议温和一些:默认只推送一个permissionChanged事件,客户端收到后把本地权限快照更新,同时服务端对后续指令做实时授权拦截。只有涉及安全封禁(比如账号被冻结)才强制断开。强制断开看起来简单,实际上会导致大量客户端同时重连打爆网关,所以不到万不得已不要用。
4.3 断线重连时的授权上下文恢复
SignalR客户端断线重连是家常便饭,但如果重连时没有把授权上下文恢复,用户会看到消息从“实时在线”变成“只能拉到历史记录”,体验很差。我的做法是在重连请求里带上refresh_token,服务端用refresh_token换新的短时内部凭证,同时从Redis里恢复上一次会话的授权快照,包括用户当前的角色、数据作用域、未过期的委托授权。恢复之后客户端能无缝继续收消息,不需要重新授权。
这里有一个细节:授权快照里要记录旧Token被替换的时间点,防止客户端带着一个已经被吊销的委托权限来重连。实力测下来,重连成功率达到99.2%,大部分失败案例是客户端网络直接断了几十分钟导致refresh_token过期,这种情况让用户重新登录是合理的,不需要过度设计。
5. MCP协议服务接入:把工具开放给AI时的授权边界
5.1 MCP服务在授权链路中的角色
MCP(Model Context Protocol)服务在NetCoreKevin里承担的是标准化工具开放层。模型通过MCP协议调用工具时,不是直接连业务服务,而是先到MCP Server注册的工具路由表,路由表里写了工具名、入参Schema、对应业务接口、权限码。MCP Server收到工具调用请求后,先做协议层的数据校验,然后把请求转给AuthorizationService做权限评估。
这里的关键点是工具路由表不能只写“能调哪个接口”,还要写清调用这个工具需要哪个权限点。比如工具有send_order_notification,它对应的接口是通知服务,但它需要的权限点是order.notify,而不是notification.write。把权限点从接口实现细节里抽象出来,业务服务改接口的时候不会影响工具授权模型,这在快速迭代期非常重要。
5.2 工具级授权分档:低风险自动放行,高风险要求确认
不同工具的风险等级完全不一样。查天气、算价格这类只读工具,我通过配置文件设为LOW_RISK,智能体带着会话凭证就能直接调,不需要再问用户;但发送消息、提交订单、导出数据这类写操作,全部设为HIGH_RISK,AuthorizationService会生成一条待确认授权记录,并把确认请求推给用户端。用户确认后授权记录写入Redis并设置过期时间,比如5分钟有效,过期后Agent要再调同类工具必须重新确认。
这个机制还有一个附加效果:所有高风险工具调用都会在授权记录里留下requestContext,包括用户当时看到的会话摘要、工具入参、意图描述。万一出了误操作,审计系统能还原“AI为什么调这个工具、用户看到了什么、用户点了什么”的完整链路。这也是我建议所有接MCP服务的团队必须做的事——不要只做工具网关,却漏了工具调用的事后审计。
5.3 MCP调用链路的TraceId透传
MCP调用的授权有一个隐藏难题:它天然是异步多轮的。模型可能先问用户“你要不要给张三发消息”,用户确认后模型才真正发起调用。这中间隔了十几秒甚至更久,如果每轮请求都独立授权,就丢失了上下文。我的做法是MCP Server在收到模型请求时生成traceId,同时带上agentSessionId和pendingConfirmId,这三个标识会贯穿整个授权评估过程。
{ "traceId": "a3f8c1e9-9b2d-4f7e-a1c0-2b5d6e7f8a90", "agentSessionId": "sess_20250612_001", "principal": { "type": "AI_AGENT", "id": "agent_018", "ownerUserId": "user_042" }, "resource": { "type": "MCP_TOOL", "id": "send_order_notification", "permissionPoint": "order.notify" }, "confirmId": "cfm_88213", "evaluationMode": "REALTIME" }授权日志按traceId归档之后,排查问题时能看到模型在某个会话里一次完整的“申请-确认-执行-结果反馈”过程。我实测过,没有TraceId之前,MCP调用出问题只能靠猜,有了链路标识,基本10分钟就能定位到具体是哪一轮决策出了问题。
6. Quartz定时任务里的授权上下文传播
6.1 定时任务没有HttpContext,授权上下文从哪来
Quartz定时任务框架和Web请求最大的区别是没有HttpContext,也没有自发携带Token的能力。任务执行代码里如果直接调用业务服务接口,就会因为没有主体身份而被拒绝。一开始团队有人提方案:给所有Job配一个超级管理员服务号,统一放行。这个方案我直接否了,超级管理员一旦落到定时任务里,迟早会因为一个Job代码不够严谨而变成数据灾难。
正确的做法是给Quartz增加一个JobPrincipalProvider组件。任务在调度时,从JobDataMap里读取ownerId、tenantId、taskType这些预先写入的元数据,企业务服务接口前带上一个服务身份Token,这个Token是AuthorizationService按任务维度签发的,有效期只覆盖任务执行窗口,默认30分钟。任务是代表谁执行的,权限就是谁的;任务只做系统内部的统计汇总,权限就是服务账户自己的。
6.2 用户级任务与系统级任务分开授权
我把Quartz任务分成两类。第一类是用户级任务,典型场景是“用户设置每天9点自动发送报表”,这类任务执行时必须携带用户委托授权,用户在系统里取消授权后,正在排队等待执行的任务要立刻感知。我的实现是Job触发前先向AuthorizationService提交一次preExecutionCheck,查询委托授权是否仍然有效,失效就直接跳过一次执行并记录原因。
第二类是系统级任务,比如数据对账、缓存预热、索引重建。这类任务不需要用户委托,但也不是无条件放行,而是给每个系统任务在授权表里注册一个systemTaskPolicy,任务执行时会校验该策略是否处于启用状态,防止异常情况下某个对账任务被误触发后连续跑几十次。这两类任务在Job基类里用不同入口,新手写新任务时不容易搞混。
6.3 任务执行期间的授权变更与重复执行防护
授权上下文传播还有一个容易忽视的点:任务的整个执行过程可能持续很久,不只是申请Token那一刻有效。对于长时间任务,我建议在任务内部每隔一段时间主动刷新一次授权心跳,如果发现授权已被撤销,就中止当前批处理并回滚已处理部分。这个心跳机制听起来重,但对超过5分钟的执行才有必要,短任务不需要,避免过度设计。
Quartz在多实例部署时的重复执行是老生常谈。我用数据库行锁加Redis分布式锁做了双重保护:任务触发时先尝试获取job:{jobId}:exec的Redis锁,拿不到就跳过;执行成功后释放锁并更新任务表的LastRunTime。数据库行锁作为Redis不可用时的兜底,两套机制共同保证同一时刻只有一个实例真正执行任务。因为AuthorizationService的授权上下文中包含任务实例信息,所以即使两个实例同时触发,授权服务也能识别出后到的那个是重复调用并拒绝。
7. 线上踩坑记录与排查技巧
7.1 Redis缓存穿透出现在权限点热点上
AuthorizationService上线初期,某个高频权限点被恶意批量请求打穿,Redis里没有缓存就直接打到数据库,数据库连接池瞬间占满。我加了一层本地内存缓存,用ConcurrentDictionary加上单机锁解决并发加载同一个权限点的问题,同时缓存空结果并设置短TTL,比如3秒。经过这一轮优化,即使Redis短暂不可用,授权服务自身还能撑住一段时间的本地缓存命中。
这里的心得是:授权服务虽然叫“服务”,但它的热点非常集中,就那么几十个权限点承载了80%的流量。把热点权限点的本地缓存做好,远比把整个授权结果都塞进Redis更有效。我的参数是:本地缓存容量128条,超过后用LRU淘汰,TTL不超过120秒,配合Redis里的策略版本号一起判断是否失效。
7.2 权限变更事件消费滞后导致下游服务还在用旧策略
第一次上线权限变更事件的时候,出现过管理后台改了一个用户的角色,结果用户等了将近一分钟才生效。排查发现是消息总线消费端在某个服务上堆积了。我当时没有让下游无限重试,而是给每个策略增加一个version字段,下游服务每次授权请求后都把本地缓存的version带上,如果总线上游的版本号大于本地版本,就主动回源拉一次。
这个方法比单纯加速消息消费更可靠,因为它把最终一致性的校验点从“消息消费”挪到了“每一次授权请求”。即使消息丢了,下一次请求时版本比对也能发现不一致并自动纠正。现在策略版本号已经统一打进响应头里,排查权限问题时直接看版本号就能判断缓存是否新鲜。
7.3 SignalR长连接静默掉线的排查
有段时间运维反馈在线用户数不断下降,但客户端没有任何报错。我加了连接状态事件上报才知道,大量连接是因为等待握手超时被服务端被动断开。根因是某些旧客户端的JWT过期后没有走向Token刷新,只做了TCP重连,SignalR握手时携带的是旧Token,直接被拒绝。修复方案是前端的重连逻辑里优先检查本地刷新Token,有效就先换新Token再尝试握手,而不是直接复用旧连接。
7.4 Quartz任务双实例重复执行的隐蔽场景
分布式锁没解决的一个坑是:任务执行中间Redis发生了主从切换,锁信息短暂丢失,另一个实例趁机抢锁执行了同一个任务。这类问题在关键任务上会酿成重复发送数据的事故。我给关键Job追加了幂等键:每次执行在业务表里插入一条带taskExecutionId的执行记录,业务处理时遇到重复的执行Id直接跳过。授权服务也在授权记录表里对这个taskExecutionId做了唯一索引,从调度到授权到业务执行,三层幂等才算基本闭环。
我把这套AuthorizationService在实际环境稳定运行了三个多月,日均授权请求量在千万级别,P99延迟稳定在12毫秒左右。最让我踏实的不是指标,而是权限逻辑终于有了一块统一的地基:AI智能体、实时通道、定时任务、普通Web请求都走同一条授权链路,新功能接入不再需要到处问“这个接口去哪里加权限判断”。后续我准备往策略热推演和权限模拟方向再深入一层,但那是另一个模块的事了。