Skip to content

Execution delegation

A trusted service, such as a portal or an internal app with its own users, can query curral on behalf of those users. The policy, row filters, masks, limits and audit then apply to the effective identity it names, not to the service’s API key. The contract is opt-in and works over HTTP and Flight SQL; Basic, JWT and ordinary API keys behave as before.

Generate a key with curral gen-api-key and mark it in the users file:

users.yaml
api_keys:
- name: portal
key_sha256: "<generated sha256>"
roles: [] # never used for delegated requests
delegation: true
attributes: # optional strings describing the service
tenant: tenant-a
  • A delegating key must name an effective identity on every data request. Without one, or with one curral refuses, the request gets 403 (execution identity refused): it never falls back to the key’s own roles.
  • Delegated SQL requests are read-only (SELECT), even if a policy mistakenly allows writes. Semantic document writes require the separate docs_write: true opt-in introduced in v0.8.0; see below.
  • attributes reach the policy as input.actor.attributes. Use them to scope what each key may assert, for instance a tenant. Keys must be identifiers; values are strings of at most 256 bytes.

Called with the key alone, without an identity, it grants no data access and describes the credential:

Terminal window
curl -H "Authorization: Bearer curral_..." https://curral.example.com/v1/capabilities
{"execution_context_version": 1, "delegation_required": true, "subject": "portal",
"attributes": {"tenant": "tenant-a"}, "formats": ["json","csv","ndjson","arrow"],
"docs": {"enabled": true, "scope": "schema", "ranker": "bm25", "delegated_writes": false}, "version": "v0.8.0"}

A client should check it before sending identities, and refuse a server that does not advertise the execution_context_version it implements. docs reports availability, scope, search ranker and whether the key supports delegated document writes.

Send the identity in the X-Curral-Execution-Context header (HTTP) or the x-curral-execution-context metadata key on every Flight SQL call. The value is unpadded base64url of one UTF-8 JSON object, at most 8 KiB and 8 levels deep:

{
"subject": "portal:tenant-a:user:42",
"kind": "user",
"tenant": "tenant-a",
"page": {"id": "7", "slug": "sales-dashboard"}
}
Field
subject required: a non-empty string of at most 256 bytes, without control characters or surrounding spaces. It becomes the effective user
kind optional: a short string the policy can switch on
anything else the service’s own, passed to the policy as given
roles, groups, actor refused: a service cannot assert roles

The effective identity gets only the roles that the identities: entries of the users file map to its subject; the key’s roles are not inherited.

Terminal window
ctx=$(printf '%s' '{"subject":"portal:tenant-a:user:42","kind":"user","tenant":"tenant-a"}' \
| base64 | tr '+/' '-_' | tr -d '=\n')
curl https://curral.example.com/v1/query \
-H "Authorization: Bearer curral_..." \
-H "X-Curral-Execution-Context: $ctx" \
-d '{"sql": "SELECT count(*) FROM orders"}'

Every authenticated response carries X-Curral-Effective-Subject (or the x-curral-effective-subject Flight header metadata) with the identity the request ran as, schema and dry-run responses included. Clients should check it.

Delegation covers /v1/query (dry runs included), /v1/schema, /v1/databases, document reads (GET /v1/docs, GET /v1/docs/{id}, POST /v1/docs/search), document writes with docs_write, and Flight SQL. A Flight handshake with a delegating key returns the same key, never a session token, so send the identity with every call.

With --docs-scope schema, delegated identities read only documents within their query scope. Document writes (POST, PUT, PATCH, DELETE) are denied unless the key opts in:

api_keys:
- name: portal
key_sha256: "<generated sha256>"
delegation: true
docs_write: true

docs_write is valid only on a delegating key; otherwise the users file is rejected. It enables the write path but does not grant permission: the docs_allow decision must still allow the effective identity. It receives input.user, input.roles, input.identity and input.actor along with action, doc and current. The document’s author is the effective subject.

docs_allow if {
input.actor.name == "portal"
input.identity.kind == "user"
"poet" in input.roles
}

The key cannot write without an effective identity, and SQL remains read-only.

curral does not interpret subjects: the policy decides what each service may assert. It receives:

Input Value
input.user the effective subject
input.roles roles mapped to that subject by identities:
input.identity the whole execution context
input.actor {name, auth_method, attributes} of the delegating key

Tie every rule that allows delegated requests to the actor, so one service cannot pass for another service’s users, or for your own:

package curral
import rego.v1
default allow := false
# Identities asserted by the portal key must belong to its tenant.
portal_identity if {
input.actor.name == "portal"
input.identity.tenant == input.actor.attributes.tenant
startswith(input.user, concat(":", ["portal", input.actor.attributes.tenant, ""]))
}
allow if {
portal_identity
input.identity.kind in {"user", "page"}
input.resolved
input.statement_type == "SELECT"
every t in input.tables { t == "sales.main.orders" }
}
row_filters := {"sales.main.orders": "region = 'north'"} if {
input.identity.kind == "page"
}
masks := {"sales.main.orders": {"customer_email": "redact"}} if {
input.identity.kind != "user"
}

The policy can return row filters as well as masks, and both can read the structured identity from the curral_identity session variable (empty when the request is not delegated):

json_extract_string(getvariable('curral_identity'), '$.page.slug') = slug

Compare against the identity inside SQL like this rather than building SQL strings from input.identity in Rego. Schema discovery, dry runs, protections and per-user concurrency all use the effective identity.

A key without delegation may send X-Curral-Caller-Context, in the same format, to record who it is calling for. It changes nothing but the audit trail: the request runs as the key, with its roles, filters and quotas, and the policy sees the context as input.caller. A request with a caller context is read-only. The two headers are mutually exclusive, and each may appear only once.

Audit events carry the effective user, the delegating actor, the structured identity (or caller for shared keys), the policy hash and the request ID. A refused identity is recorded as a delegation_denied event. Parameter values are never logged; SQL redaction and the fail-closed audit keep working.

The delegating service is a trust boundary: whoever holds its key can assert any identity its policy rules accept. The service is responsible for authenticating its users and deciding what they may reach before asking curral. Use one key per service or tenant, TLS, rotation with expires and proper secret storage. A signed end-user token is not required by this model.