Custodian
ReferenceHTTP API

Grants

Give a principal exactly the access it needs over HTTP, and read the combinations the server refuses at creation.

A grant gives one principal one action on one field of one collection — or, with no field named, on every field of the collection. Nothing is inherited and nothing is implied, so what a principal can see is answered by reading its grants.

Creating and revoking a grant are reserved for operator principals. Listing grants is not: any principal except an agent may read them, so a service can answer "what can this principal see?" for itself. No agent reaches any of these routes.

A grant names one of four actions. Three are read actions, and each decides how much of a field's value a read returns for this principal:

  • read_token returns a token — a stable tok_… string that stands in for the value and reveals nothing about it. A plain or masked field carries no token, so read_token on one returns nothing usable.
  • read_masked returns a masked rendering of the value — a partial value, for example a date reduced to its year or all but the last few characters hidden.
  • read_plaintext returns the value itself, in the clear. On a client_encrypted field, whose value the server cannot read, it instead lets the caller resolve the token to the sealed value on an enrolled device.

The fourth action, write, permits writing the field and does not permit reading.

Where a field-level grant and a collection-wide grant overlap, the strongest read action wins: read_plaintext beats read_masked beats read_token. Which of these a field's sensitivity can actually return is set out in the schema sensitivity table and, field by field, in the read-resolution envelope.

POST /v1/grants — create a grant

POST /v1/grants
Authorization: Bearer <operator token>
Content-Type: application/json

{
  "principal": "liberty-agent",
  "collection": "policies",
  "field": "holder_tax_id",
  "action": "read_token"
}

Five keys are read, from the top level of the body: principal, collection, field, action and expires_at. Nothing else is taken — the vault and the principal that creates the grant are the ones your token names, never values you put in the body. The nested { "grant": { … } } shape is also accepted: a grant object at the top level is unwrapped, and both shapes produce the same grant.

field is optional — omit it to grant across every field in the collection. expires_at is optional and, when present, an ISO 8601 timestamp. On success the response is 201 Created:

{
  "grant": {
    "id": 5012,
    "principal": "liberty-agent",
    "collection": "policies",
    "field": "holder_tax_id",
    "action": "read_token",
    "status": "live",
    "created_at": "2026-08-12T09:10:00Z",
    "expires_at": null,
    "revoked_at": null
  }
}

A null field in the response is the collection-wide wildcard — "every field" — written as null rather than omitted.

The combinations refused at creation

The server refuses, at creation, the combinations that could never be honoured, so the mistake is caught while you configure it rather than during an incident. Each carries a closed error code:

  • plaintext_grant_to_agent — a read_plaintext grant to an agent on a client_encrypted field, which an agent can never hold in the clear:

    {
      "code": "plaintext_grant_to_agent",
      "message": "refused: principal 'liberty-agent' is kind=agent; read_plaintext on a client_encrypted field is not grantable to an agent. Grant read_token instead, and resolve the value with `custodian exec` on an enrolled device.",
      "details": { "kind": "agent", "sensitivity": "client_encrypted", "field": "holder_tax_id" }
    }
  • collection_read_plaintext_to_agent — a read_plaintext grant to an agent across a whole collection, refused whatever the collection holds today, because a field added later would be covered by it:

    {
      "code": "collection_read_plaintext_to_agent",
      "message": "refused: principal 'liberty-agent' is kind=agent; read_plaintext across a whole collection is not grantable to an agent, because a field added later would be covered by it. Name the fields this principal needs.",
      "details": { "kind": "agent" }
    }
  • grant_to_revoked_principal — a grant to a principal that has been revoked:

    {
      "code": "grant_to_revoked_principal",
      "message": "refused: principal 'liberty-agent' was revoked at 2026-08-20T00:00:00Z; a grant to a revoked principal has no effect — the resolver treats its grants as absent — so accepting one would leave you believing access exists.",
      "details": { "principal": "liberty-agent", "revoked_at": "2026-08-20T00:00:00Z" }
    }
  • grant_expiry_in_past — an expiry already past:

    {
      "code": "grant_expiry_in_past",
      "message": "refused: the grant's expires_at 2026-08-01T00:00:00Z is already past (the time is now 2026-08-28T10:00:00Z); a grant that is dead on arrival is a configuration mistake, not access.",
      "details": { "expires_at": "2026-08-01T00:00:00Z", "now": "2026-08-28T10:00:00Z" }
    }
  • duplicate_live_grant (409) — a duplicate of a live grant:

    {
      "code": "duplicate_live_grant",
      "message": "A live grant (id 5012, granted at 2026-08-12T09:10:00Z) already covers this principal, collection, field \"holder_tax_id\" and action; a duplicate would add nothing.",
      "details": { "action": "read_token", "existing_grant_id": 5012, "created_at": "2026-08-12T09:10:00Z" }
    }
  • field_not_in_collection — a field that is not in the named collection:

    {
      "code": "field_not_in_collection",
      "message": "\"premium\" is not a field of collection \"claims\"; a grant names a field of the collection it grants on, or no field at all for every field in it.",
      "details": { "field": "premium", "collection": "claims" }
    }

A request that omits principal, collection or action is a 400 — a malformed request, not a statement about the vault's contents — naming the key it dropped, with no error code:

{
  "error": "A grant names a principal; this request did not. A grant is created from principal, collection and action, with an optional field and expires_at."
}

field and expires_at are optional, so their absence is never refused here.

A request body that is not valid JSON is also a 400, the same refusal the other body-reading routes give:

{
  "error": "the request body is not valid JSON."
}

An unknown action, principal or collection, or an unparseable expires_at, is a plain 422 or 404 naming the value, with no error code.

GET /v1/grants — list grants

Lists the vault's grants, live by default. Filter with principal (slug), collection (name) and field (name); widen with include_revoked and include_expired. Every row states its status, so "what can this principal see?" is answered from the rows alone.

GET /v1/grants?principal=liberty-agent
Authorization: Bearer <token>
{
  "grants": [
    {
      "id": 5012,
      "principal": "liberty-agent",
      "collection": "policies",
      "field": "holder_tax_id",
      "action": "read_token",
      "status": "live",
      "created_at": "2026-08-12T09:10:00Z",
      "expires_at": null,
      "revoked_at": null
    }
  ]
}

DELETE /v1/grants/:id — revoke a grant

Stamps the grant revoked_at and leaves the row.

DELETE /v1/grants/5012
Authorization: Bearer <operator token>
{
  "grant": {
    "id": 5012,
    "principal": "liberty-agent",
    "collection": "policies",
    "field": "holder_tax_id",
    "action": "read_token",
    "revoked_at": "2026-08-28T10:00:00Z"
  }
}

Revocation is idempotent: a second call reports the original revoked_at. An unknown identifier takes the shared not_found.

On this page