
做了大半年的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 IReadOnlyListIConstraintRule 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秒涉及敏感操作退款、删库、发消息的权限点强制cacheTtlSeconds0每次都实时评估这个逻辑是配置在权限点上的不需要业务服务配合。3.2 AI智能体通过AISK技能编排层申请临时工具权限AI智能体部分是本系统改造中最特殊的一块。智能体本身没有固定角色它每执行一个任务可能需要调多个工具。如果按传统方式给它分配一堆静态权限一是风险太大二是根本预测不到它在某个会话里会调什么。我们的做法是把AISKAI 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服务在授权链路中的角色MCPModel Context Protocol服务在NetCoreKevin里承担的是标准化工具开放层。模型通过MCP协议调用工具时不是直接连业务服务而是先到MCP Server注册的工具路由表路由表里写了工具名、入参Schema、对应业务接口、权限码。MCP Server收到工具调用请求后先做协议层的数据校验然后把请求转给AuthorizationService做权限评估。这里的关键点是工具路由表不能只写“能调哪个接口”还要写清调用这个工具需要哪个权限点。比如工具有send_order_notification它对应的接口是通知服务但它需要的权限点是order.notify而不是notification.write。把权限点从接口实现细节里抽象出来业务服务改接口的时候不会影响工具授权模型这在快速迭代期非常重要。5.2 工具级授权分档低风险自动放行高风险要求确认不同工具的风险等级完全不一样。查天气、算价格这类只读工具我通过配置文件设为LOW_RISK智能体带着会话凭证就能直接调不需要再问用户但发送消息、提交订单、导出数据这类写操作全部设为HIGH_RISKAuthorizationService会生成一条待确认授权记录并把确认请求推给用户端。用户确认后授权记录写入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请求都走同一条授权链路新功能接入不再需要到处问“这个接口去哪里加权限判断”。后续我准备往策略热推演和权限模拟方向再深入一层但那是另一个模块的事了。