Security Engineering · Authorization
Policy as Code: Decision Engines and Deterministic Evaluation
How a general-purpose policy engine turns rules into a decision, why partial evaluation produces residual queries, and what non-determinism costs an authorization system.
Declarative policy languages traded generality for analysability. A policy engine in the style of Datalog or a functional query language reverses that trade: it accepts an arbitrary program over structured input and produces a decision, so the policy can express anything the input permits and can be unit-tested like code. That flexibility moves the difficulty from expressing the rule to guaranteeing that the evaluator itself cannot be talked into the wrong answer.
Documents in, document out
The common shape is a query evaluated against an input document, returning an output document. The engine holds compiled policy modules, and an authorization call supplies input: the subject, the action, the resource, and context. The query names a location in the policy, and the result is the value computed there.
Because the output is a document and not a boolean, policy can return structured reasons, obligations, and remediation hints alongside the decision. That is a real advantage over a binary check when debugging is part of the operating cost, and a real hazard when a caller reads the wrong field. A policy that returns an object with a allowed field and a caller that reads the object’s truthiness rather than the field will permit every request, since a non-empty object is truthy in most languages.
Rules in this style are evaluated with complete-body semantics: a rule produces a value when all of its expressions hold, and multiple rules with the same name are combined. Some engines distinguish a complete rule, which must have exactly one value, from a partial rule, which produces a set or object built from the bodies that held. The distinction matters because a complete rule defined more than once with conflicting values is a conflict, and the engine reports an error rather than choosing.
Partial evaluation and the residual query
The most useful property of a Datalog-derived engine is that a query can be partly evaluated at compile time. Given a query and an input document with some fields unknown, the engine can evaluate everything that does not depend on the unknown fields and leave a residual expression describing the remaining work.
This is how policy engines generate filters. A query asking whether user U may read document D, given a policy that expresses the condition as a predicate over document attributes, can be specialized to the known user and turned into a query over a database: “documents where owner = U or department in U’s departments”. The result is a data-filtering predicate that the storage layer evaluates over the whole collection, which is the only way to answer a list request without fetching every row.
The cost is that the residual must be provably equivalent to the original predicate. Engines restrict the built-in functions that can participate, because a built-in with side effects or non-deterministic behaviour cannot be moved to another evaluation context. The safeness requirements of Datalog apply: every variable in a head must be bound in a positive body expression, and negation must be stratified so that a negated expression never depends on itself through a cycle. A policy that violates stratification is rejected at compile time rather than producing arbitrary results, which is the correct failure direction.
package authz
default allow := false
allow if {
input.action == "read"
some role in data.roles[input.user]
role.permissions[_] == "read:document"
input.resource.owner == input.user
}
The default declaration matters. Without it, the rule is undefined when no body holds, and an undefined value is not false. A caller that coerces undefined to a permit, or that treats a missing result as “no rule objected”, grants access on every request the policy does not explicitly mention. Engines that return undefined for a query with no matching rule make this failure easy, and the remediation is a default that states the intent.
Determinism, iteration order, and the ban on side effects
Authorization decisions must be reproducible. A policy that reads the clock, generates a random value, or makes a network call produces a decision that cannot be re-evaluated after the fact, which breaks audit and breaks caching.
Engine designers respond by restricting the built-in function set. Time is available as an input field rather than as a function, so the caller supplies the evaluation time and the decision is a pure function of the input. Rule iteration order is unspecified in some engines, which means any policy whose result depends on the order in which rule bodies are evaluated is a policy that can change outcome between versions of the engine. Set and object results are unordered by definition, so a policy returning several matching reasons must not depend on which appears first.
Network access inside evaluation is the most consequential exclusion. A policy that calls an external service to fetch an attribute is not a pure function; the decision depends on the availability and the version of a system outside the engine’s input. The practical rule is that the enforcement point resolves attributes before evaluation and passes them in the input document, which keeps the engine pure and moves the failure handling to a place where timeouts and fallbacks can be expressed.
The trade-off is input size. Passing every attribute the policy might need makes the input document large and the call expensive, while passing only what the current policy version references means a policy update can require an enforcement point update. Engines that support lazily resolved attributes reintroduce non-determinism through the back door, and the performance gain is usually not worth the loss of auditability.
Distribution and the update problem
Once policy is code, it needs the lifecycle of code: version control, review, tests, and a deployment mechanism. The enforcement points are typically many, spread across services, and they must all evaluate the same bundle version, or a request routed to one instance will get a different answer than the same request routed to another.
Bundles are distributed as signed artifacts and cached locally, with the enforcement point continuing to evaluate the last known good bundle when the control plane is unreachable. Availability is preserved and freshness is not: a policy change that fixes an over-permissive rule does not take effect on an instance that cannot reach the control plane. The staleness window is the bundle poll interval plus the time to propagate, and it is the revocation bound for any rule removed in that update.
Signing matters because the enforcement point trusts the bundle. An attacker who can serve a bundle to an enforcement point can grant themselves arbitrary permissions, so the verification key must be pinned in the enforcement point’s own configuration rather than obtained from the same channel that serves bundles.
Testing policy as a program
The advantage of a general-purpose policy language is that it can be tested with table-driven cases: input, expected output, and a description. The tests run in CI against the same compiler that will evaluate at runtime, so a syntax error or a type confusion fails the build rather than the request.
The limitation is coverage. Tests prove what was written down; they say nothing about the space of inputs the policy does not mention. A rule set that permits a request when three conditions hold, and gives no guidance on the case where one condition is undefined, passes every test that supplies all three. Adversarial policy review needs to enumerate the undefined and empty cases explicitly: empty group sets, absent attributes, and identifiers that refer to deleted objects.
The more serious limitation is the one that separates policy-as-code from provable confinement. A program over an input document can compute any function of that document, so the policy’s correctness is exactly as good as the author’s model of the input schema. Where the input schema drifts from the enforcement point’s actual request shape, the policy evaluates against fields that are absent and defaults apply silently. Passing structured requests through one typed definition, shared by the enforcement point and the policy tests, is the only durable fix, and it is an engineering practice rather than an engine feature. For the narrower problem of expressing authority as a value rather than as a rule, capability designs avoid the input-schema question entirely.
References
- Open Policy Agent documentation — Cloud Native Computing Foundation
- The Open Policy Agent Policy Language Reference — Open Policy Agent project
- Datalog and Recursive Query Processing — Green, Karvounarakis, Taylor, Bitner, Aref & Ives, Foundations and Trends in Databases, 2013