Writing and operating policies
Everything the gateway refuses or allows comes from one signed artifact: the policy bundle. This page is how you author it, change it safely, and get it back after losing a machine. For why it is built this way, see Policy & the tool catalogue.
Two properties shape the whole workflow, and both are deliberate:
- The source of truth is a git checkout, not a running server. Policies are text files reviewed like code, compiled into a signed bundle stamped with the commit sha. Nothing is edited in place on a production host.
- Compilation is byte-for-byte deterministic. The same tree, the same ref, the same key produce the same bundle, so a release can be reproduced, compared by hash, and rebuilt from scratch after a disaster.
The source tree
Section titled “The source tree”my-policies/ ← a git repository├── policies/│ ├── 00-base.cedar ← concatenated in lexicographic file order│ ├── 10-finance.cedar│ └── obsign.cedarschema ← generated; what your editor type-checks against├── tools.json ← the signed catalogue├── fail-mode.json ← what to do when the engine cannot decide├── identity/ ← optional: who may mint identities│ ├── provider.json ← issuer + audience│ └── jwks.json ← the IdP's public keys└── deployment/ ← optional: enrolled gateway origin keys ├── origin-keys.json └── attestation.json ← optional: TPM enrollments (v3)Only policies/ and tools.json are mandatory. Numeric filename prefixes
are only a convention, but since files are concatenated in lexicographic
order, they keep the order readable and stable. Only *.cedar files are read
as rules, so the generated schema sits beside them harmlessly.
Every field of every one of those files (type, whether it is required, its default, the values an enum accepts, and the compile error a mistake produces) is documented in The policy source tree, file by file. This page stays the narrative: how to think about the rules and how to operate them.
The model your rules see
Section titled “The model your rules see”A rule is permit/forbid over a principal, an action, a
resource, guarded by a when clause over the context.
Everything in this section also exists in machine-readable form, as a Cedar
schema derived from your own tools.json:
obsign-control schema --source ./my-policies # → policies/obsign.cedarschemaCommit it. obsign-control compile type-checks every rule against it and
refuses to sign a rule that reads something the gateway does not expose.
Pointing your editor at the same file gives you that check as you type.
See Your editor below. Regenerate it whenever tools.json
changes; --check fails instead of writing, which is what you want in CI.
Principals. User::"<subject>", with Group::"<name>" as parents, so
principal in Group::"dba" works, including nested groups. The subject and
the groups come from the verified token, mapped by the identity bundle’s
claim map (Keycloak, Entra and Okta shapes work with no configuration).
The User entity carries no attributes: permissions are expressed by
group membership or by scopes, never by principal.<something>.
Actions.
| Action | Triggered by | Resource type |
|---|---|---|
tool_call | tools/call | Tool::"<name>" |
resource_read | resources/read, resources/subscribe, resources/unsubscribe, and completion/complete on a resource template | Resource::"<uri>" |
prompt_get | prompts/get, and completion/complete on a prompt | Prompt::"<name>" |
sampling | server-initiated sampling/createMessage | Server::"mcp://wrapped" |
elicitation | server-initiated elicitation/create | Server::"mcp://wrapped" |
notify | server-initiated notifications/message | Server::"mcp://wrapped" |
Server::"mcp://wrapped" is a fixed literal and does not carry the
deployment’s server name: these channels are granted per server, and the
request names no stable object to key on. The operator’s --server-id
reaches rules as context.server and lands in every record, but no
resource is keyed on it. Nothing an operator types on a command line
decides a verdict the signed bundle did not already decide. Match on
context.server if you want a rule that only applies to one deployment; do
not expect a Server::"mcp://crm.internal" entity to exist.
Resource attributes. Only Tool carries attributes, and only because
the catalogue describes it: resource.destructive (bool),
resource.server (string), resource.required_scope (string, empty when
none). Resource and Prompt have none. The server mints those URIs at
runtime, so there is nothing signed to attach. Decide on the identifier via
context.target.
Context, available to every rule:
| Attribute | Type | Meaning |
|---|---|---|
context.env | string | environment declared to the gateway (--env: prod, staging, …) |
context.server | string | the wrapped server as the operator named it (--server-id); descriptive, never a resource key |
context.session | string | session identifier, also the audit chain id |
context.scopes | set of strings | scopes granted by the delegation |
context.target | string | resource URI or prompt name (capability actions) |
context.principal_kind | string | human, delegated_human or machine |
context.has_human_delegation | bool | an identifiable human sits at the root of the chain |
context.delegation_depth | long | number of delegation hops (0 without an act claim) |
context.actor_chain | set of strings | the attested RFC 8693 chain |
context.args.<name> | per the catalogue | declared call arguments — see below |
Writing rules
Section titled “Writing rules”Every rule needs an @id, and compilation refuses a rule without one. The
id is not decoration: it is what lands in the audit record as the reason a
call was allowed or refused. An anonymous rule produces an unexplainable
decision, which defeats the product.
// Deny wins over permit, always. Start with what must never happen.@id("forbid_destructive_prod")forbid (principal, action == Action::"tool_call", resource)when { resource.destructive && context.env == "prod" };
// Permission by scope, driven by the catalogue: one rule covers every tool// that declares a required_scope.@id("allow_scoped")permit (principal, action == Action::"tool_call", resource)when { resource.required_scope != "" && context.scopes.contains(resource.required_scope)};
// Permission by group (RBAC), narrowed by environment.@id("allow_dba_nonprod")permit (principal in Group::"dba", action == Action::"tool_call", resource)when { context.env != "prod" };
// Nothing irreversible without a human behind the agent. The distinction// comes from the token: a client_credentials token has no human at the root.@id("forbid_robot_destructive")forbid (principal, action == Action::"tool_call", resource)when { resource.destructive && !context.has_human_delegation };
// Resource families, matched on the identifier.@id("allow_public_docs")permit (principal, action == Action::"resource_read", resource)when { context.target like "docs://public/*" };
// Server-initiated channels are default-deny like everything else.@id("allow_sampling_for_support")permit (principal in Group::"support", action == Action::"sampling", resource);Cedar is default-deny: an act nobody permits is refused. You never need a
catch-all forbid, and you should not write one. It makes every later
permit look conditional when it is not.
The catalogue (tools.json)
Section titled “The catalogue (tools.json)”A tool absent from the catalogue is refused before Cedar runs: the gateway does not forward what it cannot describe. The catalogue is also what makes generic rules possible, by attaching reviewable metadata to each tool.
[ { "name": "delete_production_db", "server": "mcp://db", "destructive": true, "required_scope": "db:admin" }, { "name": "send_message", "server": "mcp://chat", "required_scope": "chat:write", "policy_args": [ { "name": "channel", "kind": "string" }, { "name": "amount_cents", "kind": "long", "default": 0 } ] }]destructive and required_scope are yours to define; the engine only
exposes them. Marking a tool destructive costs nothing and lets one rule
protect every dangerous tool at once, including the ones added later.
Arguments (obsign-policy/2)
Section titled “Arguments (obsign-policy/2)”policy_args declares which call arguments the policy may read. That
allowlist is a privacy boundary: anything not declared never reaches the
engine, and the log keeps args_hash, never the values.
@id("support_channel_only")forbid (principal, action == Action::"tool_call", resource == Tool::"send_message")when { context.args.channel != "#support" };| Field | Meaning |
|---|---|
name | the name under context.args |
kind | string, long (integral only — floats are refused, never rounded), bool, string_set |
at | JSON pointer into the call’s arguments; defaults to /<name> |
default | injected when the call omits the argument |
context.args is one namespace for the whole catalogue (every tool call is
the same Cedar action), so two tools cannot give one name two types.
Declaring amount as long on one tool and string on another is a
compile error naming both; rename one of them, and use at if the wire name
must stay as it is.
An argument declared without a default is required: a call that omits it is refused before Cedar runs. That is the safe direction. A rule that reads a missing field would otherwise fail-closed anyway, but with a much worse error message.
Fail mode (fail-mode.json)
Section titled “Fail mode (fail-mode.json)”What happens when the engine cannot decide, whether because the bundle is unreadable or because a rule raises an evaluation error:
{ "default": "closed", "tools": { "search_docs": "open" } }The default is closed, and a customer who wants otherwise declares it
explicitly so it shows up in a pull request. Per-tool because there is no
universally good answer: blocking a read-only search breaks production for
nothing, letting a deletion through is indefensible.
A degradation is never silent: a call allowed under a fail-open rule is
recorded as AllowFailOpen, never as a clean Allow.
Your editor
Section titled “Your editor”Cedar is a language with an editor already. AWS publishes the Cedar
extension for VS Code
(cedar-policy.vscode-cedar), built on the same Cedar 4.x engine Obsign
links against: syntax highlighting, formatting, an outline of your rules,
go-to-definition on entity types and action names, and (once it can see a
schema) essentially the validation obsign-control compile runs, live,
as you type. (The match is close but not exact: the extension is strict about
two expression forms that compile accepts with a warning; see Testing
before you deploy. It errs toward refusing,
never toward accepting, so a rule it likes is always one that can be signed.)
That last part is the reason the schema is a committed file. Generate it,
then point the extension at it, in .vscode/settings.json, inside the
policy repository:
{ "cedar.schemaFile": "policies/obsign.cedarschema", "cedar.autodetectSchemaFile": true}Commit that too. A colleague who clones the repository gets a working setup with nothing to configure, which is the whole point.
What you get, concretely: context.enviroment and principal.department
are underlined in red rather than discovered as a fail-mode event in
production; context.args. completes with the arguments your catalogue
actually declares, and their types; Server::"mcp://crm.internal" is
refused, because the only entity of that type is the fixed literal.
Two things to know:
- The editor is not the authority. It reads the schema on disk, which is
a generated file: if it is stale, the extension is confidently wrong.
obsign-control schema --source . --checkin CI is the guard, and the signing path (compile) regenerates the model fromtools.jsonitself and never trusts the file. - Air-gapped sites: take the
.vsix. The Marketplace is not reachable from a segregated network. Download the extension package once, carry it in with everything else, and install it withcode --install-extension cedar-*.vsix. It makes no network calls of its own; validation is local.
Neither the extension nor the schema is required to author policies.
compile catches the same mistakes, and it is the one that decides. The
editor just moves the discovery from minutes to seconds.
Compile, publish, deploy
Section titled “Compile, publish, deploy”# Regenerate the schema after any change to tools.json, and commit it.obsign-control schema --source ./my-policies
# Compile only — signed artifacts in ./out, nothing published.obsign-control compile --source ./my-policies \ --key ./ops-key.hex --key-id ops-2026 --out ./out
# Compile and publish an immutable release the gateways read.obsign-control publish --source ./my-policies \ --key ./ops-key.hex --key-id ops-2026 --dist /srv/obsign/distschema needs no signing key: it derives the model from tools.json and
writes a file, nothing more. In CI, run it with --check. It writes nothing
and exits non-zero if the committed schema no longer matches the catalogue,
which is the only way a stale schema can quietly mislead an editor.
--key-id defaults to ops-key. Name it yourself anyway: the id is bound to
that key material for good, so it is the thing a rotation has to change. A
publish that keeps the id while the key file changed is refused.
The version label defaults to the short sha of HEAD, and compile refuses
to stamp that sha onto a dirty working tree. A policies@<sha> citation in
an audit record must mean the bytes that commit contains. Use --label for a
tree that is not in git.
policies/obsign.cedarschema is exempt from that refusal, and only it: the
schema is derived, its bytes never enter the signed bundle, and compile
rebuilds the model from tools.json rather than reading the file. So the
two commands above work back to back (regenerate, then compile) without a
commit in between. An edited .cedar rule is still a dirty tree.
The distribution directory:
dist/├── policy-bundle.json ← current, atomically replaced├── identity-bundle.json ← current├── deployment-bundle.json ← current (when the tree has deployment/)├── manifest.json ← current, signed├── trusted-keys.json ← accumulated ops public keys└── releases/<version>/ ← immutable history, one directory per versionPublishing the same tree twice is idempotent. A version directory is written once and never rewritten; rollback is republishing an older sha, not editing anything. A key id cannot be rebound to different key material; rotation means a new id.
A policy change needs a gateway restart
Section titled “A policy change needs a gateway restart”The gateway reads the policy bundle once, at startup, and verifies its
signature against --trusted-keys before loading it. Publishing a new
bundle does not change the behaviour of a running gateway: restart it (or
roll your containers) to pick the change up.
This is the opposite of the identity bundle, which is re-read at runtime:
an IdP key rotation must not require a restart, and every reload, applied or
rejected, lands in the log as a config_reload record.
Plan changes accordingly: policy rollouts are deployments.
Order matters when you first declare arguments
Section titled “Order matters when you first declare arguments”The control plane emits bundle format /2 the moment one tool declares
policy_args, and a pre-upgrade gateway refuses a /2 bundle at startup
rather than silently enforcing less than the bundle says. So: upgrade every
gateway image first, publish the bundle that declares arguments second. A
fleet that never declares arguments keeps receiving /1 and needs nothing.
Testing before you deploy
Section titled “Testing before you deploy”Compile first. Most mistakes are compile errors by design: a rule without
@id, a duplicate tool, a fail-mode override naming a tool that does not
exist, an unusable JWKS, and every rule that reads an attribute the model
does not carry.
That last class used to be invisible until production. A rule saying
when { principal.department == "eng" } parses, compiles, ships, and then
raises an evaluation error on every call it guards, which falls to the fail
mode. Under "default": "open" that is a forbid which never forbids, and
the log records AllowFailOpen for a rule its author believes is enforcing.
The type check refuses to sign it, whatever the fail mode: the fail mode is
your answer to “the engine could not decide”, not to “this rule is
meaningless”.
The refusal always means the rule does nothing today: it raises on every call it guards, so removing or fixing it changes no enforcement you currently have. That matters when you hit this mid-incident. The repository is not stuck; the rule was already inert.
The check is Cedar’s own strict validation, with two findings deliberately downgraded to warnings, because strict mode also constrains the shape of an expression so policies stay amenable to automated analysis, and a rule can fail that while evaluating perfectly:
// Accepted, and it works. Strict Cedar wants a literal in `ip()`.@id("private_ranges_only")forbid (principal, action == Action::"tool_call", resource == Tool::"connect")when { !ip(context.args.src).isInRange(ip("10.0.0.0/8")) };The same goes for an empty set literal []. Both compile, both are printed
as [control] warning: …, and neither is silent. The alternative would have
been to delete a working capability: like "10.*" is not CIDR-equivalent and
cannot express a /12 or a /24 boundary.
One consequence for the editor: the Cedar extension validates strictly and
will underline these two forms where obsign-control compile accepts them.
The disagreement only ever runs that way (the editor is stricter than the
signer, never the reverse), so a rule your editor accepts is always one that
can be signed.
Then exercise the real binaries against a scratch WAL, which is what the quickstart does. Drive the calls you care about, and read the decisions out of the log:
obsign-ledger export --wal /tmp/t/wal --chain-id test \ --store /tmp/t/ledger --out /tmp/t/evidence.jsonpython3 - <<'EOF'import jsonfor r in json.load(open('/tmp/t/evidence.json'))['records']: p = r['payload'] if p.get('kind') == 'decision': print(p['outcome'], p.get('policy_id'), p.get('reason'))EOFThe policy_id column is the point: it tells you which rule decided, so a
call allowed by the rule you did not expect is visible immediately.
Backup and recovery
Section titled “Backup and recovery”The question to answer is not “can I restart the gateway” but “can I still
show, two years from now, what policies@a3f19c2 contained?” Every
decision record cites the bundle version that made it. Lose the tree that
produced that version and the audit trail points at something nobody can
read back.
| Artifact | Reproducible? | How to protect it |
|---|---|---|
| Policy source tree | — it is the source | git remote. Push it. That is the backup. |
| Ops signing key | No | The one irreplaceable secret — see below |
dist/ current files | Yes, from source + key | Recompile; deterministic, byte-identical |
dist/releases/<sha>/ | Yes, if you kept the tree and the key | Back it up anyway: it is the record of what was actually published, and it is small |
trusted-keys.json | Accumulated over time | Back up with your config; a gateway needs it to trust bundles |
The ops signing key
Section titled “The ops signing key”Losing it does not invalidate anything already signed: verification uses
the public half, which lives in trusted-keys.json and inside every pack.
What you lose is the ability to sign new bundles.
Recovery is a rotation: generate a new key under a new key id (re-binding
an old id is refused), republish, and distribute the updated
trusted-keys.json to the fleet. Budget for this being a fleet-wide config
change, which is exactly why the key belongs in a KMS/HSM in production, and
why the file-seed form is documented as development-grade.
Recommended layout
Section titled “Recommended layout”- The source tree lives in a git repository with at least one remote. A copy on the VM does not count: the remote has to sit on infrastructure that fails independently. In an air-gapped site, that is a second machine and a documented mirroring step, not an excuse to skip it.
- The ops key never lives only on the machine that uses it. HSM in production. If a file seed is unavoidable during a pilot, keep a sealed offline copy, and treat losing it as a rotation drill rather than a catastrophe.
dist/is backed up with your configuration — small, slow-changing, and it lets you answer “what was published on that date?” without a rebuild.- The WAL, the ledger store and the evidence packs have their own procedure: Backup, restore & retention. Those protect the proof; this page protects the ability to explain it.
Rebuilding after losing the VM
Section titled “Rebuilding after losing the VM”git clone <remote> my-policies && cd my-policiesgit checkout <the sha an audit record cites> # e.g. a3f19c2obsign-control compile --source . \ --key ./ops-key.hex --key-id ops-2026 --out ./rebuiltsha256sum rebuilt/policy-bundle.jsonThe key id has to be the one that signed the original release. It is part of what was signed, so a rebuild under a different id produces a different artifact and the hash comparison below proves nothing.
Because compilation is deterministic, that hash matches the artifact that was originally published, which is what turns “we think the rule said this” into a checkable claim. Do this once as a drill, before you need it: it proves your remote, your key custody and your version labels all work together.
Failure modes worth knowing
Section titled “Failure modes worth knowing”| Symptom | Cause | What to do |
|---|---|---|
tool "x" absent from signed catalogue | the tool is not in tools.json | add it and republish — the refusal is the feature |
compile: attribute … not found / attribute … on entity type … not found | a rule reads something the model does not expose (e.g. principal.permissions) | use context.scopes or a group; see the model table above |
warning: … extension constructors may not be called with non-literal expressions (accepted: …) | ip(context.args.x) — compiles and works; your editor will still flag it | nothing to do; it is a Cedar strict-form note, not a type error |
compile: … is not declared as a valid eid | a rule keyed on Server::"<your server>" | the resource key is the fixed literal; scope on context.server instead |
obsign.cedarschema is out of date with tools.json | the catalogue changed, the schema did not | obsign-control schema --source . and commit |
evaluation failed, fail-closed: … | a rule that type-checks but raises on some inputs (i64 overflow) | narrow the expression; a tool with declared arguments denies rather than fail-opens |
compile: rule without @id | an anonymous rule | name it — the id is the audit reason |
| compile refuses the sha | uncommitted changes | commit, or pass --label |
key id "…" already recorded with different key material | the key file was replaced while --key-id stayed the same | a rotation takes a new id; the old public key stays in trusted-keys.json, so releases it signed keep verifying |
gateway refuses to start on a /2 bundle | gateway older than the bundle format | upgrade gateways first, then publish |
| a change has no effect | policy is read at startup | restart the gateway |
AllowFailOpen in the log | the engine could not decide and fail mode said open | fix the rule; the degradation is visible on purpose |