Skip to content

Policy

A policy is a versioned Rego document that the STS evaluates at token-exchange time to determine whether to issue a mandate. This page covers the policy structure, the OPA evaluation model, policy sets, versioning, and the runtime loading behavior.

A policy is a named Rego document with a fixed entry point. It receives structured input describing the principal, resource, session, and context, and must return a decision object at data.caracal.authz.result.

Policies are stored in the policies table, versioned in policy_versions (each version is immutable), and activated through a policy_set_binding on the zone.

Every Caracal-compatible Rego policy must produce a result at data.caracal.authz.result:

package caracal.authz
import future.keywords.if
import future.keywords.in
default result := {
"decision": "deny",
"evaluation_status": "complete",
"determining_policies": [],
"diagnostics": [],
}
result := {
"decision": "allow",
"evaluation_status": "complete",
"determining_policies": ["my-policy"],
"diagnostics": [],
} if {
input.principal.type == "application"
input.resource.identifier == "resource://example"
"read" in input.resource.scopes
}

Required result fields:

FieldTypeDescription
decisionstring"allow" or "deny"
evaluation_statusstringMust be "complete" for the STS to accept the result
determining_policiesstring[]IDs or names of the rules that determined the decision
diagnosticsobject[]Arbitrary metadata; used for step-up signaling (see below)

The full input available to every policy:

{
"principal": {
"type": "application",
"id": "<application_id>",
"zone_id": "<zone_id>",
"credential_type": "token",
"agent_session_id": "<agent_session_id>"
},
"resource": {
"type": "resource",
"id": "<resource_id>",
"identifier": "resource://payments",
"scopes": ["transfer", "read"]
},
"action": {
"id": "TokenExchange"
},
"session": {
"id": "<session_id>"
},
"delegation_edge": {
"id": "<edge_id>",
"source_session_id": "...",
"target_session_id": "...",
"issuer_application_id": "...",
"receiver_application_id": "...",
"resource_id": "...",
"scopes": ["read"],
"edge_version": 1,
"path": ["<session_id_1>", "<session_id_2>"],
"graph_epoch": 42,
"constraints_json": {
"max_hops": 1,
"ttl_seconds": 300,
"budget": ["read"]
}
},
"context": {
"actor_claims": { "sub": "...", "zone_id": "...", "scope": "..." },
"subject_claims": { "sub": "...", "zone_id": "...", "scope": "..." },
"trace_id": "...",
"session_id": "...",
"agent_session_id": "...",
"delegation_edge_id": "...",
"challenge_resolved": false,
"requested_scopes": ["read"]
}
}

delegation_edge is present only when the exchange carries a delegation edge ID. session is present only when a session ID is provided.

The following Rego builtins are disabled in all Caracal policies:

BuiltinReason
http.sendPrevents network calls from policy evaluation
net.lookup_ip_addr, net.cidr_*, net.cidr_expandPrevents network inspection
opa.runtimePrevents runtime introspection
rand.intnPrevents nondeterministic decisions
time.now_nsPrevents clock-dependent decisions (use input.context timestamps if needed)

Policies that reference any blocked builtin fail validation when submitted to the API.

Each time a policy’s Rego source changes, a new version is created in policy_versions. Versions are immutable and identified by a content-addressable SHA-256 hash. The API validates Rego syntax and blocked builtins before accepting a new version.

Creating a policy version:

POST /zones/{zoneId}/policies/{policyId}/versions
{ "content": "package caracal.authz\n...", "schema_version": "2026-03-16" }

The schema_version field ("2026-03-16") identifies the input schema this policy was written for.

A policy set groups one or more policy versions into a bundle evaluated together. The bundle is described by a manifest — an ordered list of policy_version_id entries. The STS computes a manifest_sha256 over the manifest content to cache invalidation.

Creating a policy set version:

POST /zones/{zoneId}/policy-sets/{id}/versions
{ "manifest": [{ "policy_version_id": "<id>" }, ...] }

Activation: A zone has one active policy set binding at a time. Activating a policy set version makes it the live enforcement bundle for all subsequent token exchanges in that zone.

POST /zones/{zoneId}/policy-sets/{id}/activate
{ "version_id": "<version_id>", "shadow_version_id": "<optional_id>" }

The optional shadow_version_id registers a shadow binding alongside the active one. The active binding determines all enforcement decisions; the shadow binding can be inspected for testing purposes.

The STS loads policies lazily per zone:

  1. On first exchange for a zone, the STS queries the active policy set binding and compiles the Rego bundle into memory.
  2. Compiled bundles are cached in an atomic opaZoneState per zone. Cache hits are identified by manifest_sha256 — a bundle with the same SHA is not recompiled.
  3. A background poll refreshes all known zones every 60 seconds as a safety net.
  4. The Redis stream caracal.policy.invalidate is the fast path: the STS subscribes to this stream and reloads the affected zone immediately when a new binding is activated, bypassing the 60-second poll.

If policy compilation fails (syntax error, unsatisfiable rule, etc.), the STS installs the deny-all fallback for that zone. It does not crash. The error is visible in STS logs.

A policy can signal that it requires elevated authentication before allowing exchange. It does this by returning a step_up_required entry in diagnostics:

result := {
"decision": "deny",
"evaluation_status": "complete",
"determining_policies": ["payment-policy"],
"diagnostics": [{"step_up_required": "mfa"}],
} if {
input.resource.identifier == "resource://high-value-payments"
not input.context.challenge_resolved
}

When the STS detects step_up_required in diagnostics and the exchange has not already resolved a challenge, it creates a step-up challenge and returns HTTP 401 with a challenge object. See Step-Up Challenge for the full flow.