Skip to content
bitzorcas
中EN

Concept

Authorization 授权决策引擎

深入请求资源约定、策略评估器、Deny 优先合并、决策缓存、DataScope、审计和 Pipeline 短路语义。

Last updated

统一决策引擎的价值不只是把几个 if 集中到一个类,而是让所有业务模块共享同一组安全语义:谁提供事实、谁可以允许、谁可以拒绝、故障时如何关闭、缓存命中后哪些工作仍必须执行。

1. 输入契约

每次评估由四组事实组成:

输入来源关键字段
CurrentUser认证与可信上下文CallerType、UserId、TenantId、OfficeId、ClientId、角色、权限、委托信息
ResourceDescriptor业务请求或资源加载适配器Module、ResourceType、ResourceId、租户、组织、所有者、状态、金额、敏感级别
AuthorizationActionCommand/QueryView、Create、Update、Delete、Approve、Reject 等稳定动作
CancellationToken请求管线取消 Store、缓存、审计和策略计算

ResourceDescriptor 不是任意标签袋。字段会进入权限码、ABAC、ReBAC、DataScope 和决策缓存键,因此任何来自客户端的资源事实都必须在服务端重新核验。

ResourceDescriptor 的安全构造方式
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 为什么必须在事务之前

HandlerTransactionBehaviorAuthorizationDecisionServiceAuthorizationPipelineBehaviorMediatorHandlerTransactionBehaviorAuthorizationDecisionServiceAuthorizationPipelineBehaviorMediator不启动事务,不执行 Handleralt[Deny 或全 Neutral][Allow 且无 Deny]Command / QueryEvaluate(CurrentUser, Resource, Action)IsAllowed = falseForbidden ResultIsAllowed = true + DataScopenext(message)执行业务用例

Pipeline 只对同时实现 IMessage 与 IAuthorizedRequest 的消息生效,响应还必须实现 IResult<TResponse>,这样拒绝可以返回类型化失败而不是抛业务异常。

AuthorizationPipelineBehavior 的关键短路逻辑
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 不是拒绝,它表示“本策略不适用或没有证据”;最终服务才执行关闭式默认。

是否是否

遍历全部 Evaluator

出现任一 Deny?

拒绝
保留第一条 Deny 原因

至少一个 Allow?

允许
汇总 Allow 与 Obligations

拒绝
NoMatchingPolicy

下面是当前服务算法的等价、完整教学实现;它保留了最容易被误解的“继续执行所有评估器”语义:

Deny 优先的策略合并算法
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");

因此下面三组结果完全不同:

RBACABACFeature最终结果
AllowNeutralNeutralAllow
AllowDenyAllowDeny,理由来自 ABAC
NeutralNeutralNeutralDeny,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、所有者、部门、状态、金额、敏感级别;
  • 动作。

单值使用长度前缀编码,集合先排序,避免分隔符碰撞和“相同声明不同顺序”产生重复项。

是否

身份 + 资源 + 动作

长度前缀规范化

SHA-256

auth:decision:v2:{tenant}:{action}:{hash}

缓存命中?

重算当前 DataScope

执行所有 Evaluator

记录本次审计

缓存明确 Allow / Deny

必须掌握四个例外:

  1. CallerType.Delegated 总是绕过决策缓存,防止固定 TTL 跨越委托到期时间。
  2. NoMatchingPolicy 不缓存;未来补上策略后,新请求不应继续命中旧的默认拒绝。
  3. 缓存命中会用当前上下文重建用户、资源、时间、CorrelationId 与 TraceId,并重新解析 DataScope。
  4. 缓存读写异常只记 Debug 并回退实时评估,不改变安全判定。

6. DataScope 是决策输出,不是自动查询过滤

默认范围优先级为 admin → Tenant、团队成员 → Team、部门成员 → Department、有 OfficeId → Office、其他 → Own。简化构造函数没有组织仓储,只能得到 Tenant、Office 或 Own。

把 DataScope 转换为查询谓词
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. 用测试固定安全语义

Allow 与 Deny 同时出现时必须拒绝
[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. 排查决策的顺序

  1. 记录 CallerType、可信 TenantId、Resource 和 Action,不先猜角色;
  2. 计算派生权限码 {module}.{resource}.{action},核对大小写与动作后缀;
  3. 查 Reason:NoMatchingPolicy、abac:store-unavailable、Feature disabled 或关系端口错误含义不同;
  4. 对比决策缓存键的身份与资源事实,确认变更后是否正确失效;
  5. 检查 DataScope 是否由业务查询真正消费;
  6. 用 CorrelationId / TraceId 串联授权审计、业务日志和请求 Trace。

上一页:Authorization 概览 · 下一篇:RBAC 管理与角色生命周期

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%