EN · · 4 min read
An Authorization Decision Is More Than a Boolean
A source-guided look at deterministic precedence, reason codes, traces, tenant scope, and audit boundaries in a centralized authorization engine.
Deep DiveAdvancedEvolving
Revised
Historical note: May 17, 2026 marks the deterministic precedence test in the Aegis repository. The implementation continued to evolve, and this LoC review was written on August 23. The article describes repository contracts, not a claim about production traffic or scale.
At the enforcement point, authorization appears to need one bit: allow or deny. That bit is necessary and insufficient for a centralized authorization system. The moment several policy mechanisms can answer the same question, the system also needs deterministic precedence, scoped inputs, stable reasons, an explanation trace, and a record that can be investigated later.
Aegis makes those concerns visible in its request and decision contracts.
The question must be complete
A permission such as document:edit has no safe meaning without context. A
check request includes a subject, permission, object, tenant, store, contextual
tuples, optional policy context, and a bounded traversal depth.
check(
subject = user:charlie,
permission = document:edit,
object = document:roadmap,
tenant = tenant:acme,
store = store:product,
depth = 32
)
Tenant and store are not metadata added after evaluation. They determine which model and relationship data are eligible to participate in the decision. Leaving either implicit risks evaluating a valid rule against the wrong scope.
Precedence is a security property
The inspected engine evaluates ordered stages:
- explicit deny;
- relationship-based direct or rewrite checks;
- role-based fallback.
The order is observable behavior. If RBAC were evaluated first and returned early, a broad role could bypass an explicit deny. If an engine combined results using unordered iteration, identical inputs could produce different outcomes as data layout changed. A deterministic test locks the intended conflict behavior: deny wins even when another mechanism could allow.
This is why precedence belongs in tests and documentation, not only inside a method body.
Model the result as evidence
The engine returns a decision record with more than Allowed:
public sealed record DecisionResult(
bool Allowed,
string Decision,
string ReasonCode,
IReadOnlyList<string> Trace);
Each field answers a different operational question:
Allowedis the enforcement result.Decisionoffers a stable textual outcome.ReasonCodesupports tests, metrics, and trusted diagnostics.Tracerecords how the engine reached the answer.
The public API does not have to expose the complete internal trace. A caller may receive a simple forbidden response while a trusted operator sees a redacted explanation. Explainability and information disclosure must be designed together.
Separate traversal safety from policy meaning
Relationship checks can walk a graph. Graph traversal introduces operational failure modes that a flat role lookup does not have: cycles, repeated work, deep paths, and large fan-out.
The Aegis implementation uses several boundaries:
- a maximum depth supplied through the request;
- a visited set to prevent revisiting the same state indefinitely;
- request-scoped memoization for repeated subproblems;
- a cache for parsed authorization models.
These mechanisms do not change who should be authorized. They constrain how
the engine discovers that answer. A depth limit reached is therefore not the
same explanation as a policy that explicitly denies access. Collapsing both to
false loses information required for safe diagnosis.
Audit follows the decision boundary
The check use case loads the scoped model, evaluates the engine, and records an audit event. That event needs identifiers for tenant, store, subject, action, object, result, and reason. It should not become an unbounded dump of every piece of policy data.
Audit and application logging have overlapping fields but different purposes. Logs help operate software. Authorization audit reconstructs a security-relevant decision. Retention, integrity, access, and privacy rules should reflect that difference.
An audit sink can also fail. The product must decide whether that failure blocks the authorization response, queues a durable record, or emits an operational alert. There is no universal answer; silently pretending the event was stored is not one of the safe choices.
Centralization changes the failure model
Moving authorization out of individual product code reduces duplicated policy logic, but it creates a critical dependency. Designers must specify:
- timeouts and cancellation behavior;
- whether any decision may be cached and for how long;
- how model versions and invalidation propagate;
- what happens when relationship data is unavailable;
- which reason codes appear in metrics without exposing sensitive data;
- whether a caller can distinguish unavailable evaluation from a legitimate deny.
“Fail closed” describes the enforcement bias, not the entire operational model. A safe denial caused by unavailable infrastructure is still an incident signal, not an ordinary policy outcome.
Test conflicts, not only happy paths
The most valuable authorization tests place mechanisms in disagreement:
- an explicit deny and an RBAC allow both match;
- a direct relationship allows while a rewrite does not;
- tenant or store differs by one identifier;
- traversal encounters a cycle or depth bound;
- contextual tuples add a relationship for one request only;
- the same subproblem is reached through multiple graph paths.
Happy-path tests show that a rule can work. Conflict tests show which rule the system actually believes.
The boundary in one sentence
An authorization API returns a boolean to its enforcement point, but an authorization system owns a deterministic and explainable decision process. Treating those as the same thing makes the interface look smaller while pushing security semantics into invisible branches.