Skip to content
bitzorcas
中EN

Concept

Authorization decision engine

Understand request resource conventions, three-valued evaluators, deny-first merging, decision caching, DataScope, audit, and pipeline short-circuiting.

Last updated

The unified engine is more than a central place for conditionals. It gives every module the same security contract: who supplies facts, which policies may allow or deny, what failure means, and which work still occurs on a cache hit.

1. Decision inputs

InputSourceMaterial facts
CurrentUserAuthentication and trusted contextCallerType, user, tenant, office, client, roles, permissions, delegation
ResourceDescriptorBusiness request or resource loaderModule, type, instance, tenant, organization, owner, status, amount, sensitivity
AuthorizationActionCommand or queryStable View, Create, Update, Delete, Approve, Reject, and other actions
CancellationTokenRequest pipelineCancellation for policy, store, cache, and audit work

ResourceDescriptor is not an arbitrary bag. Its values affect permission derivation, ABAC, ReBAC, DataScope, and the decision cache key. Revalidate every client-supplied resource fact on the server.

Construct trusted resource facts
var invoice = await invoiceReader.GetRequiredAsync(request.InvoiceId, cancellationToken);
// Tenant, state, amount, and classification come from a trusted read model.
// ResourceId remains a stable business key for ReBAC and cache partitioning.
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);

A collection request may omit ResourceId, but ReBAC will then be Neutral. A list that needs relation filtering must query the relation port for allowed resource IDs; an aggregate collection Allow does not filter rows automatically.

2. Pipeline ordering

HandlerTransaction behaviorDecision serviceAuthorization behaviorMediatorHandlerTransaction behaviorDecision serviceAuthorization behaviorMediatorNo transaction and no handleralt[Deny or all Neutral][Allow with no Deny]Command / QueryEvaluate subject, resource, actionIsAllowed = falseForbidden ResultIsAllowed = true + DataScopenext(message)Execute use case

The behavior applies to messages implementing IAuthorizedRequest; the response implements IResult<TResponse> so denial returns typed failure instead of a business exception.

Pipeline short-circuit
public static class AuthorizationErrors
{
public static readonly Error Denied =
Error.Forbidden("Authorization.Denied", "Authorization check failed.");
}
var decision = await decisionService.EvaluateAsync(
currentUser.User,
message.Resource,
message.Action,
cancellationToken);
// The decision service already resolved DataScope and audited this call.
// Do not call next after denial; later transaction and handler behavior must not run.
if (!decision.IsAllowed)
{
return TResponse.Failure(
AuthorizationErrors.Denied.WithDescription(decision.Reason));
}
return await next(message, cancellationToken);

3. Three-valued policy and final merge

An evaluator returns Allow, Deny, or Neutral. Neutral means that this evaluator has no applicable evidence; only the decision service applies the fail-closed default.

YesNoYesNo

Run every evaluator

Any Deny?

Reject
keep first denial reason

At least one Allow?

Allow
collect matches and obligations

Reject
NoMatchingPolicy

This complete teaching implementation preserves the important fact that an Allow does not stop later evaluation:

Deny-first merge algorithm
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);
// Collect an Allow, but continue because a later evaluator may deny.
if (result.Verdict == PolicyVerdict.Allow)
{
allows.Add(new PolicyMatch(result.PolicyName, result.Reason));
obligations.AddRange(result.Obligations);
}
// Keep a stable first reason while still running the remaining evaluators.
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");
RBACABACFeatureFinal result
AllowNeutralNeutralAllow
AllowDenyAllowDeny with the ABAC reason
NeutralNeutralNeutralDeny with NoMatchingPolicy

4. What evaluator order changes

CoreRuntime registers RBAC, AppScope, Null ABAC, and Null ReBAC. Persistence then adds real ReBAC, ABAC, and Feature evaluators. Neutral placeholders can coexist with real evaluators without changing the verdict.

Order changes the first recorded denial, the ordering of matched Allows and obligations, and when an expensive store is called. It does not change Deny’s global priority, the all-Neutral default, or whether DataScope and audit run.

An optimization that short-circuits Feature before ABAC would require an explicit protocol change and new audit semantics; moving registration lines is insufficient.

5. Decision cache

Before hashing, the v2 cache key includes caller type, authentication, user, tenant, office, client, sorted roles and permissions, delegation facts, all resource fields, and action. Values use length-prefixed encoding; claims are sorted; SHA-256 hides sensitive facts in the key.

YesNo

Identity + resource + action

Length-prefixed canonical form

SHA-256

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

Cache hit?

Resolve current DataScope

Run every evaluator

Audit this call

Cache explicit Allow / Deny

Four exceptions matter:

  1. Delegated callers bypass the decision cache so a fixed TTL cannot cross the grant expiry.
  2. NoMatchingPolicy is not cached; adding policy should take effect on the next call.
  3. A hit rebuilds user, resource, action, time, correlation, and trace fields and resolves DataScope again.
  4. Decision-cache get/set failures fall back to live evaluation and do not change the verdict.

6. DataScope is output, not an automatic filter

The full resolver chooses Tenant for admin, then Team, Department, Office, and finally Own. The dependency-free resolver only supports Tenant, Office, and Own.

Convert DataScope into a tenant-safe query
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));
}
// Scope narrows the trusted tenant; it never replaces tenant isolation.
// Unknown future scope values fall back to Own rather than expanding visibility.
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),
};

The caller obtains departmentIds and teamResourceIds through trusted organization or relation ports.

7. Audit semantics

AuthorizationDecision records the verdict and reason, matched Allows, DataScope, obligations, user snapshot, resource, action, UTC time, CorrelationId, and TraceId. A cache hit still records this call, so audit volume represents calls rather than policy recomputations.

The audit sink is best-effort: an exception cannot change an already computed decision. Operations must nevertheless alert on sink failure and lag; best-effort does not mean an audit outage is healthy.

8. Fix the contract with tests

An explicit Deny defeats an earlier Allow
[Fact]
public async Task Explicit_Deny_Should_Win_Over_Allow()
{
// Install a valid permission Allow followed by an attribute Deny.
var service = CreateDecisionService(
new FixedEvaluator(PolicyEvaluation.Allow("rbac", "permission:billing.invoice.approve")),
new FixedEvaluator(PolicyEvaluation.Deny("abac", "amount-limit")));
// The resource amount activates ABAC, and the first denial reason remains stable.
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");
}

The repository already has AuthorizationDecisionServiceTests, AuthorizationDecisionCacheTests, AuthorizationPipelineBehaviorTests, and PolicyEvaluatorContractTests for these core semantics.

9. Diagnosis order

  1. Capture CallerType, trusted TenantId, Resource, and Action before guessing about roles.
  2. Compute {module}.{resource}.{action} and check suffix and case.
  3. Distinguish NoMatchingPolicy, ABAC store failure, Feature disabled, and a ReBAC port error.
  4. Compare identity and resource cache facts and the invalidation target.
  5. Confirm the business query consumes DataScope.
  6. Join authorization audit, request trace, and business logs through CorrelationId and TraceId.

Previous: Authorization · Next: RBAC lifecycle

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%