@o3co/auth.policy-verifier.cedar
v0.14.0
Published
Last updated: 2026-09-25
Readme
@o3co/auth.policy-verifier.cedar
Last updated: 2026-09-25
Co-resident Cedar policy evaluation for auth.policy-verifier, as an optional plugin package.
The whole Cedar policy set is evaluated by a real Cedar evaluator and enters the engine's AND-evaluation as one rule in one group. TypeScript rule groups keep working beside it: TS collectors gather the facts, Cedar policies write judgment over them — no entity store to build or sync.
Which evaluator is a deployment decision, made by dependency rather than by
config: this package owns policy loading, the attribute-to-entity mapping and
the rule, and hands the request to whichever CedarEngine is registered.
@o3co/auth.policy-verifier.cedar-wasm is the
in-process one (official @cedar-policy/cedar-wasm, ~15µs per decision, no
network); importing it is all it takes. See Engines.
This decouples policy language from PDP topology. Native logic stays
TypeScript — no DSL is ever required — and a deployment that adopts Cedar here
is not binding itself to this verifier: the same .cedar files load unchanged
into an embedded evaluator later, or into a cedar-agent when laid out one
policy per file (see Running out of process). Design: #185.
Responsibility
Role. An optional plugin. A composition passes cedarPolicyModule to the
server's createApp, which registers RequestFactsCollector and
CedarPolicyRuleCollector for config to name. It depends on
@o3co/auth.policy-verifier.core only; neither core nor the server depends on
it, and engine packages such as
cedar-wasm depend on it.
Owns.
- Policy loading and the policy revision (
loadPolicySource,computePolicyRevision). - The mapping from merged attributes to a Cedar request, with entities synthesized inline.
- The rule: how an engine's answer becomes pass, fail or a logged deny
(
onNoDeterminingPolicy, evaluation errors, the revision check). - The
CedarEngineport and the process-wide engine registry and selection. - The four attribute keys it reserves (see Reserved attribute keys).
- The out-of-process
httpengine (a cedar-agent client).
Does not own.
- An in-process evaluator. It has no Cedar dependency;
cedar-wasmsupplies one and pins its version. - An entity store. Entities are built per request, one hop deep.
- How rule groups combine. That is core's AND-evaluation; the Cedar policy set is one group in it.
- Parsing the request. The server does that, through the configured resource
parser, before any collector runs;
RequestFactsCollectoronly copies the result into attributes.
Why a separate package. Cedar is opt-in: nothing of it loads unless a
deployment imports this package. Its vocabulary (the request* attribute
keys, entity mapping) stays out of core, whose ATTR_* constants are
reserved for OAuth/OIDC/RBAC concepts. The evaluator lives one package further
out so that which evaluator runs is a dependency choice, not a config change.
Responsibility, role and invariants of the source directory: src/README.md.
Usage
import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
import { cedarPolicyModule } from "@o3co/auth.policy-verifier.cedar";
import "@o3co/auth.policy-verifier.cedar-wasm"; // registers the in-process engine
import { builtinKeyResolversModule, createApp } from "@o3co/auth.policy-verifier.server";
const app = await createApp({
pathResolver: import.meta.resolve,
config,
modules: [builtinCollectorsModule, builtinKeyResolversModule, cedarPolicyModule],
});attribute {
collectors = [
{ collector = "PayloadSubjectIdCollector" }
# Promotes action / resourceType / resourceId into attributes — the rule
# pipeline never sees the request, so the Cedar request is built from these.
{ collector = "RequestFactsCollector" }
]
}
rule {
collectors = [
{ collector = "CedarPolicyRuleCollector"
# *.cedar files, sorted and concatenated — byte-identical to what a
# Cedar agent would load. XOR an inline `policies = "..."` string.
policyDir = "config/policies"
# What the group answers when no policy determined the request.
# "deny" is the default; see "No determining policy" below.
onNoDeterminingPolicy = "deny"
# Which registered engine evaluates the set. Optional: absent, the
# preferred registered one — "wasm" whenever cedar-wasm is imported.
# engine = "wasm"
principal {
# type = "User" # default
# idAttribute = "userId" # default (ATTR_USER_ID)
attributes { dept = "department" } # principal.dept == "eng"
parents { Group = "groups" } # principal in Group::"admins"
}
resource {
attributes {
# entity-reference form: resource.owner == principal
owner = { attribute = "resourceOwner", entityType = "User" }
}
}
context { mfa = "mfaVerified" } # context.mfa
}
]
}Request-context fields reach Cedar the same way everything else does — as
attributes. Promote them with the builtins' RequestContextAttributeCollector
(the declared-allowlist trust boundary, #123) and map them here; nothing
undeclared can reach a policy.
Reserved attribute keys
Loading this package reserves the four keys it owns —
requestAction, requestResourceType, requestResourceId,
requestResourceRaw — in core's attribute key registry
(reserveAttributeKeys, exported by @o3co/auth.policy-verifier.core). A
RequestContextAttributeCollector mapping whose to names one of them is then
refused at boot, naming this package.
That matters most for requestResourceId, which RequestFactsCollector writes
only when the parsed resource carried an id. For an id-less resource such as
document nothing else writes it, so a mapping
{ from = "rid", to = "requestResourceId" } would have met no competing writer:
{"resource":"document","action":"read","context":{"rid":"someone-elses-doc"}}
would have been decided as document::"someone-elses-doc", with the entity
chosen by the caller's own request body. Where the resource does carry an id
the two writers collide instead and AttributeConflictError denies — fail-closed,
but an unannounced denial rather than a refusal at boot.
The reservation happens at module scope, so it is in place before any collector
of any package is constructed: a composition can only name
RequestFactsCollector or CedarPolicyRuleCollector in config by importing this
package. If you write a collector that promotes caller-supplied data, consult
RESERVED_ATTRIBUTE_KEYS (live, not a snapshot) and reserve your own keys the
same way — see docs/extending.md.
Semantics
Layered PDP. Cedar's own semantics (forbid overrides permit) hold inside the group; the group ANDs with every TypeScript group. During migration both are active and compose only toward strictness.
No determining policy denies by default. When no
permitand noforbidmatched,onNoDeterminingPolicydecides what the group answers:| value | the group | choose it when | | --- | --- | --- | |
"deny"(default) | fails | Cedar is authoritative over the surface it is asked about — including the common case where it is the only rule group | |"abstain"| passes | Cedar is one group beside TypeScript rules that own the rest of the surface, and having no opinion outside its own coverage is the intent |"deny"is Cedar's own implicit deny, and it is the default because the default is what a first deployment gets: with Cedar as the only rule group, an abstention passes the group and therefore passes the request — the one composition where abstaining is indistinguishable from allowing, and also the simplest one to assemble. The surrounding engine composes rule groups with default-deny; this matches it."abstain"is the migration posture and stays a first-class choice: while the policy set covers part of the surface and the TypeScript rules hold the rest, a request outside Cedar's coverage should be decided by the group that does cover it. Selecting it is a statement that another group will decide — so a pipeline whose only rule group is Cedar should not."abstain"is refused at boot with an out-of-process engine (engine = "http"): an agent that restarted comes back with no policies and answers every request "no determining policy" — exactly what a covered request that matched nothing answers — so under"abstain"everyforbidwould silently stop applying.Neither value affects an evaluation error, which always denies (below).
Evaluation errors always deny, and log. Cedar reports a policy that reads a missing attribute as
denywith the cause only in diagnostics — and an erroringforbidstops forbidding, so the top-level decision can readallowexactly when it is least trustworthy. The rule checksdiagnostics.errorsfirst: any error is a deny regardless ofonNoDeterminingPolicy, and is logged unlesslogEvaluationErrors = false.Entity synthesis is one hop. Principal and resource entities are synthesized per request from the attribute map — attributes, group membership, entity references. Multi-hop dereference and hierarchy walks need an entity store; needing them is the signal to move to a full Cedar deployment, which the same
.cedarfiles already fit.
Policy revision: which policies decided
Every answer of the rule reports the evaluation behind it (#244). The report
goes to the reporter core hands verify / decide for that one call, and core
carries it onto the decision: into the decision log event always, and into the
response under verify.evaluationInResponse = "include". The rule itself still
answers a boolean, so an evaluator that predates the reporter reads a deny as a
deny.
A denial is cedar_deny whether or not a policy produced it; this is what
tells them apart:
| the rule answered because | evaluation.status | the revision |
| --- | --- | --- |
| Cedar answered without errors — a permit, a forbid, or no policy determining the request | completed | revision, when the engine vouches for it |
| Cedar answered with evaluation errors | failed | revision, when the engine vouches for it |
| the call itself failed | failed | revision: null — nothing answered, so nothing vouched |
| the engine named a revision other than the one loaded | failed | revision: null — it answered from a policy set this verifier did not load |
| the engine named no revision, under requireConfirmedRevision | failed | revision: null |
| the request could not be built from the attributes, so Cedar was not asked | not_invoked | no revision key at all |
What the revision is. sha256: and the lowercase hex SHA-256 of
auth.policy-verifier.cedar/policy-set/v1\n
<bytes>:<name>,<bytes>:<text>, ← once per *.cedar file, in load orderwhere <bytes> is the decimal UTF-8 byte length of what follows it, <name>
is the file's bare name (policies for the inline set) and <text> its
contents. loadPolicySource({ policyDir }).revision gives it for a directory
— the same filter, sort and decoding the collector uses — and
computePolicyRevision(files) for a list already in hand, so CI can compute
the revision of what it is about to ship and compare it with what production
reports. It is computed once, at boot, from the very files handed to the
engine.
- Same contents, same revision — on any replica, at any mount path. The directory is deliberately not part of it, and no path ever appears in a decision.
- A changed policy changes it even when its policy id does not. Policy ids
are positional (
policy0) under wasm and file names under http; neither moves when a policy's text is edited. The revision does. - A rename changes it, because under the http engine the file name is the policy id the agent is given.
- The framing is there because concatenation is not injective:
"X\n"+"Y"and"X"+"\nY"are one policy text and two policy sets.
It is the text as loaded that is hashed, so what changes the text changes
the revision, policy for policy identical or not. Three things do that across
machines: line endings (a checkout with core.autocrlf turns \n into
\r\n), a byte-order mark (kept as U+FEFF by the UTF-8 decoder), and the
Unicode normalization of a non-ASCII file name (NFC on one filesystem, NFD on
another). Pin line endings for *.cedar in .gitattributes, and keep file
names ASCII, if replicas built on different machines must agree.
What it does not cover. The collector's mapping, onNoDeterminingPolicy,
the engine and its version, and the attributes the request was decided over
all shape an answer too. The revision says which policies were evaluated. It is
not a promise that evaluating them again gives the same answer; to explain a
decision later, keep the deployed version and its config beside it.
revision versus loadedRevision. revision is a claim about what was
evaluated, so it is set only when the engine vouches for that answer. The
wasm engine does: the set is compiled in-process from the files that were
hashed, under an id nothing else holds. The http engine cannot: cedar-agent
answers { decision, diagnostics } and does not say which policies it holds,
and a restarted agent comes back empty. There the rule reports revision: null
and the digest of what it pushed at boot as loadedRevision — worth recording,
and not proof of what ran. An engine that names a revision other than the
loaded one is answering from a policy set this verifier did not load, and the
rule fails it closed — and logs it whatever logEvaluationErrors says, because
that is a fault of the deployment and not a policy reading a missing attribute.
requireConfirmedRevision = true is for a deployment whose audit has to
name the policies behind every decision: an answer nobody vouched for becomes a
deny — always logged, like the mismatch above — instead of a permit of unknown
origin. It is refused at boot over
an engine that does not declare confirmsRevision — today, engine = "http" —
because there every answer would be that deny.
Engines
This package has no evaluator of its own. CedarPolicyRuleCollector loads the
policy set, builds the Cedar request — principal, action, resource, context
and the synthesized entities, inline — from the merged attributes, and hands
both to a CedarEngine. The port and its types are defined, with their
documentation, in src/engine.mts; the contract in short:
- An engine has a
name(the registry key and the config value that selects it), declares up front whether its policy sets answer asynchronously (async), and may declare that every answer names the revision it evaluated (confirmsRevision, #244). Both declarations come beforeload, so the collector can refuse an unsafe configuration before loading has side effects. loadis called at boot with thePolicySourceand a load context carrying the collector's whole config entry (an engine reads and validates its own keys there, such as the http engine'sendpoint) and a logger. It returns the loaded set, possibly as a promise, or throwsCedarEngineErrorto refuse the set.- A loaded set answers either synchronously (in-process), or asynchronously
with an
AbortSignalfor the rule's deadline (over I/O). Itsasyncmust match the engine's declaration. - An answer is a
CedarDecision: the decision, the determining policies and the evaluation errors as text, and optionally the revision it was evaluated against. A call that failed outright rejects withCedarEngineError.
CedarDecision.revision is the port's confirmation contract (see Policy
revision): an engine names
source.revision on an answer only if that answer provably came from the set
compiled from that source. It is per answer, not per load, because that is
the only moment the claim is true of a remote engine.
load may be asynchronous — a remote engine takes the policy set over the
network — and a set that cannot be loaded still refuses to start: the
collector's factory (CedarPolicyRuleCollector.create, what the module
registers) awaits it, and createApp awaits the factory.
The kind of policy set the engine returns decides the kind of rule the
collector builds — a Rule asked through verify, or an AsyncRule asked
through decide under the server's verify.ruleTimeoutMs — and nothing else
changes: config, mapping, the answer table above and the cedar_deny the
decision reports are identical across engines. Switching engines is a
dependency change, not a config change (#225).
An engine package registers itself when imported (registerCedarEngine, at
module scope), and the collector picks one by its config engine key
(resolveCedarEngine in src/engine.mts):
| config engine | result |
| --- | --- |
| absent | the first registered of wasm, http — so wasm whenever @o3co/auth.policy-verifier.cedar-wasm is imported |
| a registered name | that engine; an explicit choice wins over the preference |
| "wasm", package not imported | refuses to start, naming @o3co/auth.policy-verifier.cedar-wasm |
| anything else | refuses to start, listing what is registered |
Name the engine. Left absent, which process decides authorization is
settled by what the dependency graph happens to import — a transitive
dependency that pulls in the wasm package flips a deployment from
out-of-process to in-process — and nothing in the config shows it. The
collector therefore logs a warning at boot when engine is absent (cedar
engine selected by default — set engine …), and states the engine at info
when it is named. The registry is process-wide, not per copy of this package,
so two copies on the graph still see one set of engines.
The engines that ship today:
| engine | package | runs | when |
| --- | --- | --- | --- |
| wasm | @o3co/auth.policy-verifier.cedar-wasm | in-process, synchronous, tens of µs per decision on a small set; ~12 MB of wasm instantiated at import | the policy set is cheaper to evaluate than a loopback hop — most of them; see Sizing |
| http | this package (cedarHttpEngine, registered by importing it) | out of process: a cedar-agent over HTTP, asynchronous, one loopback hop per decision | the policy set is large enough that evaluating it in-process competes with request handling, or the evaluator should scale and upgrade apart from the verifier |
Both engines see the same request — principal, action, resource, context and the synthesized entities, inline — and answer through the same table. What differs is where the evaluator runs and what the deployment carries: the wasm package, or a second process.
Running out of process
engine = "http" (or simply not importing the wasm package) sends every
decision to a cedar-agent. The standalone template ships it as a compose
profile that shares the verifier's network namespace, so the agent listens on
loopback and nothing outside the container pair can reach it — the same trust
boundary the verifier's own bind address draws:
echo "CEDAR_AUTHENTICATION=$(openssl rand -hex 32)" >> .env # the agent's token, required
docker compose --profile cedar up --build- Where the agent is.
endpointin the collector's config entry, else theCEDAR_ENDPOINTenvironment variable. Neither refuses to start withno cedar engine endpoint is configured, naming both ways out — this engine is also what a deployment gets when it has not imported the wasm package, and that is the mistake worth naming. The template's compose file setsCEDAR_ENDPOINT=http://127.0.0.1:8180, the profile's address. A base URL: the agent's/v1/policiesand/v1/is_authorizedare appended. Redirects are not followed, so this must be the URL that answers those calls itself: behind an ingress that answers with a 3xx, every decision is a deny and a 3xx to the policy load fails boot. Plainhttp://is accepted for loopback hosts only; a routable agent must behttps://(the rulejwksUrifollows, and for the same reason: the request carries the subject's attributes and the answer is an authorization).authenticationin config, elseCEDAR_AUTHENTICATION, is sent verbatim as theAuthorizationheader when the agent was started with one. A token that cannot be sent as a header — an ASCII control character other than a tab inside it, a character above U+00FF — fails boot, naming where it came from but not its value. - Authenticate the agent. An agent without
--authenticationis a write oracle over the policy set: anything that reaches its port canPUT /v1/policiesapermit(principal, action, resource);and every later decision allows. Loopback in a shared network namespace narrows who can reach it; it does not make the token optional, and outside a private namespace it MUST be set. The template's compose profile starts the agent withCEDAR_AGENT_AUTHENTICATIONfrom the sameCEDAR_AUTHENTICATIONthe app sends. A load the agent refuses as unauthenticated fails boot namingCEDAR_AUTHENTICATION. - The verifier owns the policies. At boot the engine
PUTs the policy set to the agent, one entry per.cedarfile with the file's name as the policy id, so the agent holds exactlyconfig/policiesand nothing is converted or mounted twice. Boot retries an unreachable agent for 10 s (a compose sibling may be a few hundred milliseconds behind) and then refuses to start. The error names the cause —connect ECONNREFUSED …,getaddrinfo ENOTFOUND …, a TLS error — or, when the deadline passes while an attempt is still waiting, says no answer came in time, with how the attempt before failed if one did. A set the agent refuses fails boot at once, with the agent's message. - One policy per file. cedar-agent stores policies one by one, so each
.cedarfile — and an inlinepoliciesstring — must hold exactly one policy; a file with two is refused at boot. The wasm engine concatenates and does not care, so a corpus laid out one policy per file runs under both, and reads better under this one:diagnostics.reasonthen names files (10-permit-eng) rather thanpolicy0. - One collector per agent.
PUT /v1/policiesreplaces the agent's whole set, so a secondCedarPolicyRuleCollectorpointed at the same agent is refused at boot rather than silently overwriting the first. Every loopback spelling of a host (localhost,127.0.0.1,[::1]) on one port counts as the same agent. - Connections are not capped. The engine uses the process's global
fetchdispatcher with keep-alive, so concurrent decisions map one-to-one onto concurrent agent connections. Each call is bounded byverify.ruleTimeoutMsand the decision byverify.evaluateDeadlineMs, and a batch byverify.batchConcurrency; a deployment expecting floods on the verifier should size the agent for that concurrency, or put a connection-limiting proxy in front of it. - An answer is bounded. At most
maxAnswerBytesof one answer is read, and held until it is parsed, for each concurrent call. Unset, it is 1 MiB — the default, exported asCEDAR_ANSWER_MAX_BYTES; there is no environment variable for it. A longer answer is refused rather than held for the rule deadline, since a process out of memory takes every route down. An answer's determining-policy and error lists grow with the policy set, so a large set can answer honestly past 1 MiB: the refusal says so, and the fix is a highermaxAnswerBytesin the collector's entry — a whole number of bytes from 1 KiB to 256 MiB, written as a number (maxAnswerBytes = 4194304) or a numeric string, which is what a HOCON env substitution of a variable of your own naming delivers (maxAnswerBytes = ${?MY_ANSWER_BYTES}). Anything else —4 MiBunquoted is a string — refuses to start. An error's body is read up to the smaller ofmaxAnswerBytesand 1 MiB, since it becomes the log line. - Failure after boot is a deny. An agent that is unreachable, answers
non-2xx, breaks off its answer, answers more than
maxAnswerBytes, or answers something that is not a decision makes the rule fail and log (cedar authorization call failed); the log line'sreasonnames the cause, as the boot error does. An agent that is up but has lost the policy set — restarted, or recreated bydocker compose up— is not a failure it can see: it answers "deny, no determining policy" to every request, which is whyonNoDeterminingPolicy = "abstain"is refused with this engine; under the default it denies everything until the verifier is restarted and pushes the set again. Each call runs under the server'sverify.ruleTimeoutMs(default 2000 ms), answeringrule_timeoutwhen the agent is slower than that. - Two Cedar versions. cedar-agent 0.2.2 evaluates with cedar-policy 2.4; the wasm package with the Cedar 4 release it pins (see Version pinning). Policies written to the older grammar run under both; a policy using a newer construct will be refused by the agent at boot, which is the right place to find out.
- The response contract is cedar-agent 0.2.x's.
POST /v1/is_authorizedmust answer{ decision, diagnostics: { reason: [...], errors: [...] } }; an answer missing either list is refused, and the call denies. The items of both lists are read as text — an agent on a newer Cedar that reports structured errors still has its errors seen (and denied on), rather than every answer refused as malformed. - Entities travel inline, and the agent reads them. Each call carries the
request's entities in
entities; nothing is written to the agent's own/v1/datastore. #225 left open whether cedar-agent honours inline entities rather than its store; the v0.10.0 release audit confirmed it against the pinnedpermitio/cedar-agent:0.2.2: a policy reading an inline attribute decides on it as the wasm engine does, and one reading an attribute the entity lacks answersDenywith the error indiagnostics.errors, which the rule denies on.
Sizing: which engine
Measured, not guessed: one request of the shape the collector sends (two
entities inline, one context field), against policy sets in which every
policy has to be considered, 2000 decisions each. Apple M3, Node 26,
@cedar-policy/cedar-wasm 4.12.0 in-process; permitio/cedar-agent:0.2.2
in Docker Desktop on the same machine, reached through a published loopback
port. Microseconds per decision:
| policies | wasm p50 | wasm p99 | agent p50 | agent p99 | | ---: | ---: | ---: | ---: | ---: | | 1 | 34 | 90 | 346 | 1281 | | 10 | 45 | 78 | 328 | 1897 | | 100 | 134 | 238 | 395 | 791 | | 1000 | 1111 | 1409 | 969 | 1595 |
How to read it:
- The wasm figure is time the verifier's event loop is blocked; the agent
figure is mostly waiting, and costs the loop only the
fetchoverhead (on the order of 100 µs of it). - On this setup the hop costs roughly 300 µs, so in-process wins outright up to a few hundred policies, and the two cross near a thousand policies of this shape — where the agent's native evaluator is already faster than the wasm one and the verifier stops paying for evaluation on its own loop.
- Docker Desktop on macOS routes the published port through a virtual
machine; a Linux host with the compose profile's shared network namespace
pays less for the hop, which moves the crossover down. Policies with heavier
conditions or larger entity sets move it down too. Measure your own corpus
before deciding: the script is three
fetchcalls and onestatefulIsAuthorizedloop. - Below the crossover, choose the agent anyway when the evaluator should scale or upgrade apart from the verifier, or when the 12 MB wasm should not be in the image. Above it, choose wasm anyway when a second process is not worth operating. The switch is a dependency change — config, mapping and policies stay put.
Version pinning
The Cedar evaluator's version is the engine package's concern:
@o3co/auth.policy-verifier.cedar-wasm pins @cedar-policy/cedar-wasm
exactly (the version is in its package.json),
because Cedar minor releases can carry policy-language changes and
upgrades should be deliberate. This package depends on no evaluator.
