统一决策引擎的价值不只是把几个 if 集中到一个类,而是让所有业务模块共享同一组安全语义:谁提供事实、谁可以允许、谁可以拒绝、故障时如何关闭、缓存命中后哪些工作仍必须执行。
1. 输入契约
每次评估由四组事实组成:
| 输入 | 来源 | 关键字段 |
|---|---|---|
CurrentUser | 认证与可信上下文 | CallerType、UserId、TenantId、OfficeId、ClientId、角色、权限、委托信息 |
ResourceDescriptor | 业务请求或资源加载适配器 | Module、ResourceType、ResourceId、租户、组织、所有者、状态、金额、敏感级别 |
AuthorizationAction | Command/Query | View、Create、Update、Delete、Approve、Reject 等稳定动作 |
CancellationToken | 请求管线 | 取消 Store、缓存、审计和策略计算 |
ResourceDescriptor 不是任意标签袋。字段会进入权限码、ABAC、ReBAC、DataScope 和决策缓存键,因此任何来自客户端的资源事实都必须在服务端重新核验。
var invoice = await invoiceReader.GetRequiredAsync(request.InvoiceId, cancellationToken);
// 租户、状态、金额和敏感级别全部来自受信 Read Model,不接受请求体自报。// ResourceId 保持业务稳定键,供 ReBAC 和决策缓存共同分区。var resource = new ResourceDescriptor( Module: "billing", ResourceType: "invoice", ResourceId: invoice.Id, TenantId: invoice.TenantId, OfficeId: invoice.OfficeId, OwnerUserId: invoice.OwnerUserId, DepartmentId: invoice.DepartmentId, Status: invoice.Status, Amount: invoice.TotalAmount, Sensitivity: invoice.Sensitivity);集合级请求可以不提供 ResourceId,但这意味着 ReBAC 会返回 Neutral。若列表查询需要关系过滤,应先从关系端口得到允许的资源集合,不能假设一次集合级 Allow 会自动过滤每一行。
2. Pipeline 为什么必须在事务之前
Pipeline 只对同时实现 IMessage 与 IAuthorizedRequest 的消息生效,响应还必须实现 IResult<TResponse>,这样拒绝可以返回类型化失败而不是抛业务异常。
public static class AuthorizationErrors{ public static readonly Error Denied = Error.Forbidden("Authorization.Denied", "授权检查未通过。");}
var decision = await decisionService.EvaluateAsync( currentUser.User, message.Resource, message.Action, cancellationToken);
// 决策服务已经完成 DataScope 解析和本次审计,Pipeline 只负责是否继续。// 拒绝直接构造 Forbidden Result;next 不会执行,因此后续事务也不会开始。if (!decision.IsAllowed){ return TResponse.Failure( AuthorizationErrors.Denied.WithDescription(decision.Reason));}
return await next(message, cancellationToken);3. 三值策略与最终合并
单个评估器返回 Allow、Deny 或 Neutral。Neutral 不是拒绝,它表示“本策略不适用或没有证据”;最终服务才执行关闭式默认。
下面是当前服务算法的等价、完整教学实现;它保留了最容易被误解的“继续执行所有评估器”语义:
var allows = new List<PolicyMatch>();var obligations = new List<AuthorizationObligation>();string? firstDenial = null;
foreach (var evaluator in evaluators){ var result = await evaluator.EvaluateAsync(user, resource, action, cancellationToken);
// Allow 会被汇总,但不能提前返回;后续评估器仍可能显式拒绝。 if (result.Verdict == PolicyVerdict.Allow) { allows.Add(new PolicyMatch(result.PolicyName, result.Reason)); obligations.AddRange(result.Obligations); }
// 保留第一条拒绝用于稳定诊断,其他拒绝仍完成计算但不会覆盖 Reason。 if (result.Verdict == PolicyVerdict.Deny && firstDenial is null) { firstDenial = $"{result.PolicyName}:{result.Reason}"; }}
var isAllowed = firstDenial is null && allows.Count > 0;var reason = firstDenial ?? (isAllowed ? "Allowed" : "NoMatchingPolicy");因此下面三组结果完全不同:
| RBAC | ABAC | Feature | 最终结果 |
|---|---|---|---|
| Allow | Neutral | Neutral | Allow |
| Allow | Deny | Allow | Deny,理由来自 ABAC |
| Neutral | Neutral | Neutral | Deny,NoMatchingPolicy |
4. 评估器顺序的真实含义
CoreRuntime 按 RBAC → AppScope → Null ABAC → Null ReBAC 注册基础评估器;持久化组合再追加真实 ReBAC → ABAC → Feature。注释表达了期望顺序,但 DI 集合中占位与真实评估器可以同时存在,Neutral 占位不会改变结果。
顺序影响:
- 多个 Deny 同时出现时,最终
Reason使用第一条; - Allow 的
MatchedPolicies与 obligations 保留遍历顺序; - 每个评估器都会执行,后置 Store 调用仍产生延迟和故障机会。
顺序不影响:
- Deny 对 Allow 的全局优先级;
- 全 Neutral 的关闭式拒绝;
- DataScope 和审计是否执行。
如果将来需要“Feature 先拒绝就不查昂贵 ABAC Store”,必须显式引入短路协议并重新定义审计与多拒绝诊断,不能只调换注册行。
5. 决策缓存
AuthorizationDecisionService 使用 SHA-256 保护后的稳定缓存键。编码前事实包括:
- 调用者类型、认证状态、用户、租户、Office、Client;
- 排序后的角色、权限和委托者角色;
- 委托 Grant 与到期时间;
- 资源模块、类型、实例、租户、Office、所有者、部门、状态、金额、敏感级别;
- 动作。
单值使用长度前缀编码,集合先排序,避免分隔符碰撞和“相同声明不同顺序”产生重复项。
必须掌握四个例外:
CallerType.Delegated总是绕过决策缓存,防止固定 TTL 跨越委托到期时间。NoMatchingPolicy不缓存;未来补上策略后,新请求不应继续命中旧的默认拒绝。- 缓存命中会用当前上下文重建用户、资源、时间、CorrelationId 与 TraceId,并重新解析 DataScope。
- 缓存读写异常只记 Debug 并回退实时评估,不改变安全判定。
6. DataScope 是决策输出,不是自动查询过滤
默认范围优先级为 admin → Tenant、团队成员 → Team、部门成员 → Department、有 OfficeId → Office、其他 → Own。简化构造函数没有组织仓储,只能得到 Tenant、Office 或 Own。
var decision = await authorization.EvaluateAsync( currentUser.User, new ResourceDescriptor("billing", "invoice"), AuthorizationAction.View, cancellationToken);
if (!decision.IsAllowed){ return Result.Failure<PagedResult<InvoiceDto>>( AuthorizationErrors.Denied.WithDescription(decision.Reason));}
// 每个分支都保留可信 TenantId;数据范围只能收窄,不能替代租户隔离。// 未显式列出的范围回落 Own,避免新增枚举值意外扩大可见数据。var query = decision.DataScope switch{ DataScope.Tenant => invoices.Where(x => x.TenantId == currentTenant.Id), DataScope.Office => invoices.Where(x => x.TenantId == currentTenant.Id && x.OfficeId == currentUser.OfficeId), DataScope.Department => invoices.Where(x => x.TenantId == currentTenant.Id && departmentIds.Contains(x.DepartmentId)), DataScope.Team => invoices.Where(x => x.TenantId == currentTenant.Id && teamResourceIds.Contains(x.Id)), _ => invoices.Where(x => x.TenantId == currentTenant.Id && x.OwnerUserId == currentUser.UserId),};这段代码展示转换边界,departmentIds 与 teamResourceIds 应由调用方的可信组织或关系端口提供。
7. 授权审计
决策记录包含 Allow/Reason、命中的允许策略、DataScope、obligations、用户快照、资源、动作、UTC 评估时间、CorrelationId 和 TraceId。缓存命中也写本次审计,因此审计反映调用次数而不是“策略实际计算次数”。
审计 Sink 是 best-effort:异常会被吞掉,不阻断已经计算出的授权结果。这是可用性选择,不代表审计缺失可以静默运营。生产环境应对 Sink 异常、落库延迟和审计断流建立独立告警。
8. 用测试固定安全语义
[Fact]public async Task Explicit_Deny_Should_Win_Over_Allow(){ // 先给出一个有效 Allow,再由后置策略显式 Deny。 var service = CreateDecisionService( new FixedEvaluator(PolicyEvaluation.Allow("rbac", "permission:billing.invoice.approve")), new FixedEvaluator(PolicyEvaluation.Deny("abac", "amount-limit")));
// 资源金额让 ABAC 拒绝生效,最终理由必须稳定地保留第一条 Deny。 var decision = await service.EvaluateAsync( CreateUser(), new ResourceDescriptor("billing", "invoice", Amount: 100_000m), AuthorizationAction.Approve, CancellationToken.None);
decision.IsAllowed.Should().BeFalse(); decision.Reason.Should().Be("abac:amount-limit");}实际仓库对应测试位于:
AuthorizationDecisionServiceTests:全 Neutral、RBAC、AppScope、Deny 优先、DataScope、审计字段;AuthorizationDecisionCacheTests:缓存键分区、声明排序、委托绕过、明确拒绝与默认拒绝;AuthorizationPipelineBehaviorTests:拒绝短路和事务顺序;PolicyEvaluatorContractTests:RBAC 与 AppScope 调用者互斥。
9. 排查决策的顺序
- 记录
CallerType、可信 TenantId、Resource 和 Action,不先猜角色; - 计算派生权限码
{module}.{resource}.{action},核对大小写与动作后缀; - 查
Reason:NoMatchingPolicy、abac:store-unavailable、Feature disabled 或关系端口错误含义不同; - 对比决策缓存键的身份与资源事实,确认变更后是否正确失效;
- 检查 DataScope 是否由业务查询真正消费;
- 用 CorrelationId / TraceId 串联授权审计、业务日志和请求 Trace。