Skip to content

Policy evaluation

celine.sdk.policies. Seven repositories import it. Rego is evaluated in process via regorus — there is no OPA server in the request path, so a policy decision costs no network hop and no service can be "up but unauthorised" because OPA is down.

Each service ships its own *.rego bundle. This module loads and evaluates it; it never supplies policy of its own.


The bundle

REQ-0050 — the bundle is read once, frozen, and evaluated many times

load() reads the policies and data into an immutable bundle at startup. Evaluating before load() is a PolicyEngineError, not a silent deny: a service whose policies never loaded is misconfigured, and answering "denied" would hide that.

REQ-0051 — every .rego under the directory is loaded, except the tests

The search is recursive. Files ending _test.rego are Rego's own unit tests and are excluded — loading them would put test rules in the production decision path.

REQ-0052 — a missing policy directory is a startup failure

PolicyEngineError naming the path. The alternative — an engine that loads nothing and denies everything — is indistinguishable at runtime from a correctly restrictive policy.

REQ-0053 — the loaded packages are inspectable without evaluating anything

has_package, get_packages and policy_count report what the bundle contains, read from each file's package declaration. A service uses this to fail fast when the policy it intends to query is not in the image.

REQ-0054 — policy data is optional

A data directory may supply JSON documents, loaded recursively alongside the policies. No directory, or a configured directory that does not exist, means no data — not an error.


Deciding

REQ-0055 — each thread evaluates on its own engine, built from the shared frozen bundle

The bundle is immutable and shared; the engine is per-thread and never mutated across threads. This is what makes the engine safe inside a threaded server without a lock on the request path.

REQ-0056 — the input document has one fixed shape

subject, resource, action, environment. Enum values are serialised as their string values, so a policy matches "user" and not "SubjectType.USER". An anonymous request is subject: null rather than a missing key, so a policy can test for it.

Subject.anonymous() builds the anonymous principal; it is a subject of type anonymous with the id anonymous, never None pretending to be a user.

REQ-0057 — a decision is an allow, a reason and optional filters, and an undefined rule denies

data.<package>.allow decides. A rule that is undefined for this input, or a result of any non-boolean shape, yields allowed=False with an empty reason: the default is deny at every failure mode inside the bundle, including a malformed result.

data.<package>.reason is carried through when it is a string, for logs and for the message a user sees.

Querying a package the bundle does not contain is not a reliable deny. Where some loaded package shares its path, the rule is merely undefined and the decision is a denial; where nothing shares it, the query is an error and the exception reaches the caller. Neither is a contract worth depending on: a service querying a package it never shipped is misconfigured rather than unauthorised, which is what REQ-0053 exists to catch — at startup, not per request.

REQ-0058 — filters are optional and never fail a decision

data.<package>.filters supplies row-level predicates for data queries. A bundle that defines none, or whose filters are unusable, produces an empty list — the allow decision stands on its own.


The decision cache

REQ-0059 — decisions are cached on the semantic content of the request

The key is a hash of subject, resource and action, scoped by policy package. Two identical requests hit; a request differing in any of them misses.

REQ-0060 — volatile environment fields are excluded from the key

timestamp, request_id and trace_id change on every request and change no decision. Including them would give a cache with a hit rate of zero, which is worse than no cache because it also costs memory.

REQ-0061 — a cached decision says that it is cached

The returned decision carries cached=True while the stored one is left untouched, so the next reader is not told the decision came from a cache of a cache. A freshly evaluated decision reports cached=False.

REQ-0062 — the cache can be bypassed for one call or disabled entirely

skip_cache=True evaluates and — deliberately — does not populate the cache either. A disabled cache passes every call through. Both leave the wrapped engine's behaviour identical.

REQ-0063 — entries can be invalidated by policy or wholesale, and the count is reported

invalidate(policy) drops that policy's entries; invalidate() drops everything. The number removed is returned so the caller can log it.

REQ-0064 — the cache reports hits, misses and its own size

stats gives size, maxsize, hits, misses and a rounded hit_rate. A cache nobody can measure is a cache nobody can size.

REQ-0065 — the cached engine is a drop-in for the engine

is_loaded, policy_count, load, has_package, get_packages and evaluate are delegated unchanged, so a service can wrap or unwrap the cache without touching call sites. evaluate() — the raw query path — is never cached, because its query string is arbitrary.