id: cedar-engine title: Cedar is the second engine, and it never permits by accident status: open crates: [didbot-policy, didbot-pds, didbot-lexicon] dependsOn: [policy] exitCriterion: > A bot.did.policy with engine cedar refuses a write, the refusal carries the statement's @id and @reason, a document with a typo in a field name refuses every write in its scope rather than permitting one, and a policy over a collection with a public lexicon is rejected at load when it reads a field that lexicon does not have. #
cedar-engine #
The envelope names its engine. The first is the regex engine; the second is
Cedar, through the cedar-policy crate.
Cedar brings a typed schema, a validator, time arithmetic and a hierarchy
operator that matches the admission tree. It also has three behaviours that
are wrong for a deny-only server unless the engine is built around them, and
this epic exists to write those down before the crate is added. Everything
below was checked against Cedar 4.12 (language 4.5) with the four documents
and ten requests in the policy lexicon workshop.
The request model #
One Cedar namespace, Didbot. The principal is always the account. The
account tree is Cedar's entity hierarchy: entity Account in [Account],
with each account's parents being the accounts that admitted it. Reach still
lives in the binding, which decides which documents load at all; a document
may narrow further with principal in, and that is allowed and unremarkable.
Each action is one Cedar action with a fixed resource type and context record. This table is the schema, and the schema is generated from it, not written twice.
| action | resource | context |
|---|---|---|
record.write |
Record { collection, rkey, space? } |
operation, client?, value?, existing?, now |
blob.write |
Blob { mimeType, sniffed, size, space? } |
now |
oauth.authorize |
Client { id } |
scopes, now |
oauth.token |
Client { id } |
grant, scopes, now |
A write's client is the Client its OAuth token was issued to, the
same entity the two OAuth actions take as their resource. So
context.client.id like "https://app.example/*" at the write reads like
resource.id like "https://app.example/*" at sign-in. A write made with
the account's own credential has no client, and a statement tests
context has client before reading it.
The principal of every action is an Account carrying kind, handle and
depth (edges between it and the operator) when the gate knows them; a
statement guards each with has.
now and every timestamp are Cedar datetime values, so a document can say
context.now.durationSince(context.existing.createdAt) > duration("1d").
A create over a tombstone arrives with existing populated and operation
set to create, per account-data.
Three rules the engine enforces #
Every statement loads under its @id. Handed a document as one text,
Cedar numbers the statements policy0, policy1, and a refusal names
nothing. The loader splits the document into statements, requires @id on
each, requires them to be unique within the document, and loads the set
keyed by them. A forbid also requires @reason; the refusal on the wire is
the envelope's name, the statement's @id and its @reason, the same
three things the regex engine returns.
An evaluation error is a refusal. Cedar treats a policy that errors as
not satisfied. For a permit that is safe. For a forbid it means a misspelled
field name, or a missing has guard on an optional field, permits the write
and tucks the error into the diagnostics. The engine reads the diagnostics
after every decision and refuses when they hold any error, naming the
statement and saying the check could not complete. A document with a typo
therefore refuses everything in its scope, which is loud, rather than
permitting silently.
A document may only forbid. Until policy-exemptions
ships closed subtrees, the server's baseline is one permit(principal, action, resource) and every loaded document is checked at build time to
contain forbids only. A permit in a document is a load error on the
dashboard, because a permit that does nothing today would start doing
something the day closure exists.
Validation, and what happens without it #
Cedar validates a document against a schema at load, in strict mode. The
schema's record types are generated per collection from the lexicon the
server knows for it, either shipped in didbot-lexicon or resolved through
the published com.atproto.lexicon.schema record for the NSID's authority.
Validation happens in the candidate build that policy already
describes: the current set keeps serving, the candidate is built and
validated in memory, every failure is reported to the dashboard, and the
swap happens only when the candidate is clean. Seeing a new collection for
the first time triggers a resolve and a rebuild.
A collection with no resolvable lexicon is evaluated unvalidated: Cedar
runs without a schema and reads whatever attributes the record has. This
works, and it was tested: nested fields, like on a nested URI, and set
membership all evaluate. What it loses is catching mistakes at load. A typo
surfaces at the first write that reaches the path, as a refusal under the
rule above, and the dashboard shows evaluation errors as a second-class
signal beside load errors. The open-record schema feature that would let a
schema cover the typed part and leave the record open is experimental in
Cedar and is not used.
What Cedar cannot say #
- Regex, or case folding. Strings have
likewith*. A content rule over free text is a regex document. - Order. JSON arrays become Cedar sets. A rule about the first image or the order of facets is not a Cedar rule.
- Counts or history. Cedar sees one request. Counters and historical facts are the stateful evaluator's, in the resolver and state seams the policy epic keeps open.
Not this epic #
Templates and template links, partial evaluation, and the datetime
extension beyond now, durationSince and duration literals. Each is a
seam Cedar leaves open and none has a scenario yet.