Skip to content

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.
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.

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:

Terminal window
obsign-control schema --source ./my-policies # → policies/obsign.cedarschema

Commit 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.

ActionTriggered byResource type
tool_calltools/callTool::"<name>"
resource_readresources/read, resources/subscribe, resources/unsubscribe, and completion/complete on a resource templateResource::"<uri>"
prompt_getprompts/get, and completion/complete on a promptPrompt::"<name>"
samplingserver-initiated sampling/createMessageServer::"mcp://wrapped"
elicitationserver-initiated elicitation/createServer::"mcp://wrapped"
notifyserver-initiated notifications/messageServer::"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:

AttributeTypeMeaning
context.envstringenvironment declared to the gateway (--env: prod, staging, …)
context.serverstringthe wrapped server as the operator named it (--server-id); descriptive, never a resource key
context.sessionstringsession identifier, also the audit chain id
context.scopesset of stringsscopes granted by the delegation
context.targetstringresource URI or prompt name (capability actions)
context.principal_kindstringhuman, delegated_human or machine
context.has_human_delegationboolan identifiable human sits at the root of the chain
context.delegation_depthlongnumber of delegation hops (0 without an act claim)
context.actor_chainset of stringsthe attested RFC 8693 chain
context.args.<name>per the cataloguedeclared call arguments — see below

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.

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.

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" };
FieldMeaning
namethe name under context.args
kindstring, long (integral only — floats are refused, never rounded), bool, string_set
atJSON pointer into the call’s arguments; defaults to /<name>
defaultinjected 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.

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.

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 . --check in CI is the guard, and the signing path (compile) regenerates the model from tools.json itself 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 with code --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.

Terminal window
# 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/dist

schema 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 version

Publishing 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.

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.

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:

Terminal window
obsign-ledger export --wal /tmp/t/wal --chain-id test \
--store /tmp/t/ledger --out /tmp/t/evidence.json
python3 - <<'EOF'
import json
for 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'))
EOF

The 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.

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.

ArtifactReproducible?How to protect it
Policy source tree— it is the sourcegit remote. Push it. That is the backup.
Ops signing keyNoThe one irreplaceable secret — see below
dist/ current filesYes, from source + keyRecompile; deterministic, byte-identical
dist/releases/<sha>/Yes, if you kept the tree and the keyBack it up anyway: it is the record of what was actually published, and it is small
trusted-keys.jsonAccumulated over timeBack up with your config; a gateway needs it to trust bundles

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.

  1. 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.
  2. 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.
  3. dist/ is backed up with your configuration — small, slow-changing, and it lets you answer “what was published on that date?” without a rebuild.
  4. 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.
Terminal window
git clone <remote> my-policies && cd my-policies
git checkout <the sha an audit record cites> # e.g. a3f19c2
obsign-control compile --source . \
--key ./ops-key.hex --key-id ops-2026 --out ./rebuilt
sha256sum rebuilt/policy-bundle.json

The 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.

SymptomCauseWhat to do
tool "x" absent from signed cataloguethe tool is not in tools.jsonadd it and republish — the refusal is the feature
compile: attribute … not found / attribute … on entity type … not founda 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 itnothing to do; it is a Cedar strict-form note, not a type error
compile: … is not declared as a valid eida 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.jsonthe catalogue changed, the schema did notobsign-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 @idan anonymous rulename it — the id is the audit reason
compile refuses the shauncommitted changescommit, or pass --label
key id "…" already recorded with different key materialthe key file was replaced while --key-id stayed the samea 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 bundlegateway older than the bundle formatupgrade gateways first, then publish
a change has no effectpolicy is read at startuprestart the gateway
AllowFailOpen in the logthe engine could not decide and fail mode said openfix the rule; the degradation is visible on purpose