---
title: "Cedar policy"
description: "Read, store, test, and publish a workspace's Cedar authorization policy through the Obol control plane."
---
Cedar is the permission layer: compiled data evaluated in-process by the gateway, default-deny, never a network hop per decision. These routes move one workspace's Cedar source through draft, compile, and publish. See [Policy overview](/policy/overview) and [Publishing](/policy/publishing).
Three rules shape this surface.
`GET /policy` does not create a `Policy` row. A workspace with no row gets a read-only *projection* of what a first publish would produce — the default Cedar source and the stable `pol_default_…` id — not a claim that anybody published anything.
`PUT /policy` stores source and stamps `draft_updated_at`. It does not compile, does not create a revision, and does not touch the workspace's snapshot version.
`POST /policy/publish` compiles the stored draft outside every lock, then commits only if nothing moved underneath. It never accepts Cedar in its body, so nothing can go live that was not first stored and audited as a draft.
| Route | Action | Roles |
|---|---|---|
| `GET /policy` | `workspace.read` | viewer and above |
| `PUT /policy` | `policy.draft` | developer and above |
| `POST /policy/test` | `policy.draft` | developer and above |
| `POST /policy/publish` | `policy.publish` | admin and above |
`POST /policy/test` borrows `policy.draft` rather than `workspace.read`, so a frozen workspace answers `409` even though nothing is written. Testing is part of authoring, a frozen workspace cannot publish the result, and a read-only role should not be able to spend compiler capacity.
## Policy object
The `operator_policy` contract, frozen in `packages/proto/operator_policy.schema.json`. It never carries `entities_json`, the workspace snapshot, or a revision's compiled bundle — those are gateway documents.
Currently `1`.
Prefixed `pol_`. For a workspace with no stored policy this is the stable `pol_default_`.
Prefixed `ws_`.
The stored draft source, or the default policy when no row exists.
`sha256:<64 hex>` over the canonical UTF-8 Cedar **source only**.
RFC 3339 UTC. With no `Policy` row this is when the workspace acquired its default policy, not a claim that an operator edited anything.
`not_run`, `valid`, or `invalid`. A plain read reports `valid` only when the stored draft is byte-equal to a source that did compile, because it is the one that was published; otherwise `not_run`.
Compiler diagnostics. Empty on a plain read. Each entry has `severity` (`error` or `warning`), `code`, `message`, and optional `line` and `column`.
`state` (`published` or `pending`), `requested_version` (at least 1), and `published_version` when a version has been published.
Prefixed `rev_`. Present once a revision exists.
RFC 3339 UTC. Present once a revision exists.
`sha256:<64 hex>` over the compiled bundle — source **plus** the entity snapshot as of publish time. Present once a revision exists.
`draft_hash` and `published_hash` cover different inputs and are never comparable. `published_hash` is also not the hash of what is currently live: it is frozen at publish time, while the running bundle is rebuilt from the revision's source plus *fresh* entities, so the two diverge after any key or connection change without the policy moving. "Is my draft the one that is live?" is answered by `current_revision_id` plus `publication`, never by comparing hashes.
## Get the policy
`GET /api/v1/workspaces/{workspace_id}/policy`
Requires `workspace.read`. Returns `200` with the policy object. Writes nothing.
Workspace id, prefixed `ws_`.
```bash cURL
curl "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/policy" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN"
```
```json Response
{
"schema_version": 1,
"policy_id": "pol_default_acme_prod",
"workspace_id": "ws_acme_prod",
"cedar_text": "@id(\"agents-list-and-complete\")\npermit(\n principal is Obol::Agent,\n action in [Obol::Action::\"list\", Obol::Action::\"complete\"],\n resource\n);\n",
"draft_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"draft_updated_at": "2026-09-04T12:00:00Z",
"validation_state": "not_run",
"diagnostics": [],
"publication": { "state": "published", "requested_version": 1, "published_version": 1 }
}
```
The `draft_hash` above is a placeholder. The real value is the SHA-256 of the exact `cedar_text` bytes returned in the same response.
### Errors
| Status | Condition |
|---|---|
| `401` | No session, or the session did not verify. |
| `404` | No such workspace, or the operator is not a member. |
| `500` | `{"detail": "workspace publication state is unavailable"}` — the workspace has no publication row, which is a broken invariant rather than a state worth naming. |
## Store a draft
`PUT /api/v1/workspaces/{workspace_id}/policy`
Requires `policy.draft`. Returns `200` with the policy object reflecting the new draft. Stores source and audits it. Does not compile.
Workspace id, prefixed `ws_`.
Cedar source, 1–262144 bytes. Additional properties are rejected. The bound is part of the compiler-pool budget: a megabyte of Cedar is a payload, not a policy — the shipped contract policy is under 2 KB.
```bash cURL
curl -X PUT "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/policy" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"cedar_text": "permit(principal is Obol::Agent, action in [Obol::Action::\"list\"], resource);\n"}'
```
```json Response
{
"schema_version": 1,
"policy_id": "pol_01hxyz",
"workspace_id": "ws_acme_prod",
"cedar_text": "permit(principal is Obol::Agent, action in [Obol::Action::\"list\"], resource);\n",
"draft_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"draft_updated_at": "2026-09-04T12:31:00Z",
"validation_state": "not_run",
"diagnostics": [],
"publication": { "state": "published", "requested_version": 1, "published_version": 1 }
}
```
### Errors
| Status | Condition |
|---|---|
| `401` | No session, or the session did not verify. |
| `403` | Role does not grant `policy.draft`. |
| `404` | No such workspace, or the operator is not a member. |
| `409` | Workspace is frozen. |
| `422` | `cedar_text` missing, empty, over the size bound, or an unexpected field was sent. |
## Test a draft
`POST /api/v1/workspaces/{workspace_id}/policy/test`
Requires `policy.draft`. Compiles the stored draft against the workspace's current entities and reports diagnostics. **Writes nothing.** No body.
It uses the same compiler seam as publish, and therefore the same process-level concurrency bound and the same closed failure vocabulary, so a developer who cannot publish can still find out why a draft will be rejected before an admin tries.
Workspace id, prefixed `ws_`.
```bash cURL
curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/policy/test" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN"
```
```json Response (invalid draft)
{
"schema_version": 1,
"policy_id": "pol_01hxyz",
"workspace_id": "ws_acme_prod",
"cedar_text": "permit(principal is Obol::Agent",
"draft_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"draft_updated_at": "2026-09-04T12:31:00Z",
"validation_state": "invalid",
"diagnostics": [
{ "severity": "error", "code": "parse_error", "message": "unexpected end of input", "line": 1, "column": 31 }
],
"publication": { "state": "published", "requested_version": 1, "published_version": 1 }
}
```
Returns `200` with `validation_state` set to `valid` or `invalid` and `diagnostics` populated. An invalid draft is a successful `200` response, not an error status — the request succeeded, the draft did not compile.
{/* TODO: the diagnostic `code` values are produced by the `obol-policy-check` binary and are not enumerated in a committed contract; the example code above is illustrative. */}
### Errors
| Status | Condition |
|---|---|
| `401` | No session, or the session did not verify. |
| `403` | Role does not grant `policy.draft`. |
| `404` | No such workspace, or the operator is not a member. |
| `409` | Workspace is frozen. |
| `503` | `{"detail": "policy_compiler_busy"}` or `{"detail": "policy_compiler_unavailable"}`. |
`policy_compiler_busy` is distinct from `policy_compiler_unavailable` on purpose: nothing is wrong with the compiler or the draft, the process is over its concurrency budget. Sending an operator to debug a healthy toolchain is the failure mode being avoided. Both are closed messages — they name a condition, never a queue depth, a lane, or a workspace. The compiler's stderr, exit code, and exception text never reach a response.
## Publish
`POST /api/v1/workspaces/{workspace_id}/policy/publish`
Requires `policy.publish`. Compiles the stored draft, creates a revision, stages a snapshot for the gateway, and audits the publish.
The route runs in three phases: it snapshots `(policy_id, cedar_text, draft_hash, current_revision_id)` and closes the read transaction; compiles with no transaction open and no lock held; then re-opens a write transaction that locks the workspace and the policy and re-checks the same values. Anything that changed underneath is a `409` with no writes.
Workspace id, prefixed `ws_`.
The `draft_hash` of the draft you intend to publish, matching `^sha256:[a-f0-9]{64}$`. Take it from `GET /policy` or the `PUT /policy` response. Publishing is a confirmation of a stored draft, never a way to submit one — this body accepts no Cedar.
Optional. When supplied, the publish is rejected unless the policy's `current_revision_id` still matches. Use `null` or omit it when publishing the first revision.
Additional properties are rejected.
```bash cURL
curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/policy/publish" \
-H "Authorization: Bearer $OBOL_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"expected_draft_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"expected_revision_id": null
}'
```
```json Response
{
"schema_version": 1,
"policy_id": "pol_01hxyz",
"revision_id": "rev_01hxyz",
"draft_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"published_at": "2026-09-04T12:32:00Z",
"publication": { "state": "pending", "requested_version": 2 }
}
```
### Success response
The `operator_policy_publish` contract, frozen in `packages/proto/operator_policy_publish.schema.json`.
Currently `1`.
Prefixed `pol_`.
The revision just committed, prefixed `rev_`.
The source hash that was published — the same value you confirmed.
RFC 3339 UTC.
`state` (`published` or `pending`), `requested_version`, and `published_version` when set. A Redis outage after the commit makes this `pending`; it does not delete the revision or the audit row.
### Errors
| Status | Condition |
|---|---|
| `401` | No session, or the session did not verify. |
| `403` | Role does not grant `policy.publish`. |
| `404` | No such workspace, or the operator is not a member. |
| `409` | `{"detail": "policy draft changed"}` — the stored draft no longer hashes to `expected_draft_hash`, or another writer created the workspace's policy while this request compiled. |
| `409` | `{"detail": "policy revision changed"}` — `expected_revision_id` no longer matches. |
| `409` | Workspace is frozen. |
| `422` | The draft did not compile. The body is the **policy object** with `validation_state: "invalid"` and safe line/column diagnostics, not a message the caller would have to parse. |
| `422` | Body validation failed — a missing or malformed `expected_draft_hash`, or an unexpected field. |
| `503` | `{"detail": "policy_compiler_busy"}` or `{"detail": "policy_compiler_unavailable"}`. |