Authentication
Every endpoint except POST /auth/login expects a bearer session token in the Authorization header. The caller's identity is always resolved from that token on the server, never from the request body.
There is one authentication flow: a password login that issues a session token.
POST /auth/login saves the returned token for you — so put your real actor and password in its body first. Prefer the terminal? Copy gives you the exact curl for whatever you have typed.$ curl -X POST $LANYARD_SERVER/auth/login \
-H "Content-Type: application/json" \
-d '{"actor":"alice@example.com","password":"..."}'
200 {"token":"sess_..."}
# 2. use that token as a bearer for every other request
$ curl $LANYARD_SERVER/grants \
-H "Authorization: Bearer sess_..."
actor, requester, approved_by, or revoked_by field sent in a request body — those fields in responses are always derived server-side from the resolved caller. Forging them would let any caller impersonate anyone and defeat the authorizer.LANYARD_SESSION_TTL (default 12h) and are revocable. An unknown, expired, or revoked token resolves to the same generic 401 — the server never reveals why a token failed, so tokens cannot be probed.channel (console · api · cli, default api). It is bound to the session and reported as context.channel on every audit event that session produces, so a change made from the console is distinguishable from one made by a script. It is a claim the client makes once, not something lanyard verifies — it describes an origin and is never an authorization input. system is reserved for lanyard itself and is refused.Conventions
A few rules hold across the whole API. Knowing them up front makes every endpoint below predictable.
| Aspect | Rule |
|---|---|
| Base URL | Whatever LANYARD_ADDR is set to on the server (default http://localhost:8080). Set LANYARD_SERVER on the CLI to match. |
| Content-Type | application/json for every request with a body and every response that has one. POST /auth/logout returns 204 No Content. |
| Unknown fields | Rejected with 400. Request bodies are decoded with DisallowUnknownFields — typos can't silently be ignored. |
| Identity | Always from the resolved token, never the body. See Authentication. |
| Visibility | Every list/read endpoint is scoped to the caller. A grant or audit event the caller may not see returns 404 (grant) or is omitted (audit), indistinguishable from "does not exist". |
| Idempotency | Reads are idempotent. Writes are not — POST /grants creates a fresh grant each call, approve/revoke are state-transition guards that reject an already-terminal grant with 400. |
| CORS | Off by default (fail-safe on a security tool). To let this docs page's Run button call a different-origin lanyard server, set LANYARD_CORS_ORIGINS on the server to your origin (e.g. http://localhost:5173 for a local docs server, or * for local dev only). CORS only adds response headers — it never skips auth. The preflight advertises GET, POST, PUT, DELETE, OPTIONS and the Authorization and Content-Type headers, which is every method and header this API actually routes. |
Editing and chaining requests
Each endpoint has a Try tab: the documented example as an editable form. Run sends what is in that form — path, query string and JSON body — so the request you read and the request you send are the same thing. Invalid JSON and unresolved variables are refused locally rather than sent for the server to reject.
Most API work is a chain: one call returns an id the next call needs. Write {{name}} anywhere in a path or body and it is substituted from the Vars tray in the bar above. The obvious hops are wired already, and anything else is one click — every value in a live response is clickable, and clicking it saves it as a variable.
1 POST /auth/login → captures the session token into the bar
2 GET /me → captures {{actor}}
3 GET /catalog → captures {{entitlement}}
4 POST /grants → requests {{entitlement}}, captures {{grant_id}}
5 POST /grants/{{grant_id}}/approve
6 GET /grants/{{grant_id}} # now active, with its approval chain
localStorage so a reload doesn't lose them. That includes a live session token — point this page at a development server, and use clear all in the Vars tray when you are done.Error envelope
Every non-2xx response is a single JSON object with an error string. Client-input errors carry a human-readable message derived from the domain sentinel; internal errors return a generic "internal error" — full detail is logged server-side and never leaked, so the response cannot disclose topology, secret refs, or stack state.
500 {"error":"internal error"}
Status codes
Auth
Issue and revoke the session tokens used for every other call.
Exchange an actor + password for a session token. The only endpoint that takes identity from the body; the password is verified against the policy's bcrypt hash. Unknown actor and wrong password return the same generic 401 (anti-enumberation).
| Field | Type | Notes |
|---|---|---|
| actor | string | Required. Email-like identity declared in policy.yaml. |
| password | string | Required. Compared with the bcrypt token_ref-resolved hash. |
-H "Content-Type: application/json" \
-d '{"actor":"alice@example.com","password":"hunter2"}'
# use this token as: Authorization: Bearer sess_8f3a...
401 {"error":"authenticator: invalid credentials"}
Revokes the caller's own session. The token itself is the credential — no body, no path id. Subsequent calls with that token fail 401.
-H "Authorization: Bearer $TOKEN"
Identity
What the caller may see and change. The console asks this before it renders anything.
Report the caller's own identity and which managed resources they may view and manage. Scoped to the caller by construction — the actor comes from the token, never the request — so there is no way to ask this about someone else. manages is always a subset of views: management implies view, never the reverse, which is what makes a read-only auditor expressible. This is presentation input only; every other endpoint re-checks authority itself, so a tampered copy buys nothing but a menu item that 403s.
-H "Authorization: Bearer $TOKEN"
"actor":"alice@example.com",
"views":["audit","roles","reconciliation"],
"manages":["roles"]
}
Grants
Request, inspect, approve, and revoke time-bounded access. Every call is RBAC-checked against the caller's resolved identity and the entitlement's policy.
Request access to an entitlement for a duration. The policy engine runs first: auto_approve may return the grant already active; otherwise it returns pending, waiting on a human approve. A deny (duration over max_duration, requester not allowlisted where required) returns 403 — never 201 with a denied state. Fails closed.
| Field | Type | Notes |
|---|---|---|
| entitlement | string | Required. Four segments, provider:instance:resource:capability — e.g. aws:111111:role/prod-admin:assume. The instance names which account or org, so one broker can front several of the same vendor. |
| reason | string | Required, non-empty, not a self-approval of a vague string; bounded length. |
| duration_seconds | int | Required, > 0, and ≤ the entitlement's max_duration. |
-H "Authorization: Bearer $TOKEN" \
-d '{"entitlement":"aws:111111:role/prod-admin:assume","reason":"INC-4821","duration_seconds":7200}'
"id":"g_8f3a","status":"pending",
"entitlement":"aws:111111:role/prod-admin:assume","requester":"alice@example.com",
"reason":"INC-4821","duration_seconds":7200,
"requested_at":"2026-07-15T09:00:00Z"
}
403 {"error":"policy: access request denied"}
# 403 is also returned when the caller lacks the 'request' verb for this entitlement.
List grants visible to the caller. Optional ?status= narrows to one lifecycle state; an unrecognized value is a 400 (not silently ignored). The usecase enforces per-caller visibility — a grant you may not see is absent, never redacted.
| Param | Type | Notes |
|---|---|---|
| status | enum | Optional. One of pending · active · expired · revoked. Absent = all visible. |
-H "Authorization: Bearer $TOKEN"
"id":"g_8f3a","status":"active","entitlement":"aws:111111:role/prod-admin:assume",
"requester":"alice@example.com","approved_by":"bob@example.com",
"expires_at":"2026-07-15T11:00:00Z"
}]
Inspect one grant. "Not found" and "not visible to you" are deliberately indistinguishable — both return 404, so the existence of a grant you can't see never leaks.
| Param | Type | Notes |
|---|---|---|
| id | string | Required. Grant id, e.g. g_8f3a. |
-H "Authorization: Bearer $TOKEN"
"requester":"alice@example.com","approved_by":"bob@example.com",
"duration_seconds":7200,"reason":"INC-4821",
"requested_at":"2026-07-15T09:00:00Z",
"activated_at":"2026-07-15T09:05:00Z",
"expires_at":"2026-07-15T11:05:00Z"}
Approval progress
A chained grant also carries an approval object; a chainless one omits the key entirely rather than sending null. current_level is the index into levels awaiting a decision, and equals the number of levels once all are decided. Each level reports the roles that may satisfy it (any one is enough) and a status of approved, pending, or skipped_vacant — the last meaning no member of any of its roles could approve, so it was skipped upward. A decided level adds approver and decided_at, plus delegator when the approver acted on delegated authority: both names are recorded, so accountability is never laundered through a delegation.
Approve a pending grant. The approver must hold the approve verb on the entitlement, and cannot be the requester (no self-approval). On success the grant becomes active with activated_at and expires_at set. No request body.
-H "Authorization: Bearer $TOKEN"
"approved_by":"bob@example.com",
"activated_at":"2026-07-15T09:05:00Z",
"expires_at":"2026-07-15T11:05:00Z",
"approval":{"current_level":2,"levels":[
{"roles":["team_lead"],"status":"approved",
"approver":"bob@example.com","delegator":"carol@example.com",
"decided_at":"2026-07-15T09:04:00Z"},
{"roles":["security","compliance"],"status":"skipped_vacant"}
]}}
# non-pending grants (active/expired/revoked) reject approve
403 {"error":"grant: actor is not authorized..."}
# also returned for self-approval attempts.
End a grant early. Works on pending and active grants. The revoker must hold the revoke verb on the entitlement. A reason is optional but recommended for the audit trail. Terminal grants (expired/revoked) reject with 400.
| Field | Type | Notes |
|---|---|---|
| reason | string | Optional. Recorded in the audit event. |
-H "Authorization: Bearer $TOKEN" \
-d '{"reason":"incident closed"}'
"revoked_by":"bob@example.com",
"revoked_reason":"incident closed",
"revoked_at":"2026-07-15T09:40:00Z"}
Catalog
What is available to request, and the shapes a request form is built from.
Search the entitlements the caller may request, live from the connected providers. Scoped per caller: an entitlement the caller cannot see is absent, not marked forbidden — the catalog discloses the shape of the estate lanyard fronts, which is worth gating on its own. Cursor-paginated like /audit. Query params: ?q= to filter, ?provider= to narrow to one provider, ?cursor= and ?limit= to page.
-H "Authorization: Bearer $TOKEN"
{"entitlement":"aws:111111:role/prod-admin:assume"}
],"total":1,"next_cursor":"",
"groups":[{"provider":"aws","count":1}]}
total is the full match count, not the page size, and groups carries the per-provider counts a vendor-grouped catalog needs to render its collapsed list without fetching every page first.
The request-form templates: an entitlement pattern plus the parameters that fill its wildcards. This is what lets a console offer "request access to a repo" without the requester hand-writing a four-segment entitlement.
-H "Authorization: Bearer $TOKEN"
"pattern":"github:acme:repo/*:admin",
"name":"GitHub repository — admin",
"description":"Full control of one repository",
"params":[{"name":"repo","label":"Repository"}]
}]}
The values a template parameter can take, fetched live from the provider — the repositories an installation can see, for instance. Separate from the template itself because the shape is static config while the options change under it.
-H "Authorization: Bearer $TOKEN"
{"name":"repo","label":"Repository",
"options":["acme/api","acme/web"]}
]}
Approval chains
Who must approve what. A chain is an ordered list of levels; a level is a set of roles, satisfied by any one of them.
The merged chain view: entries provisioned from policy.yaml (read-only, provisioned:true) alongside those managed here. Each level is the list of roles that may satisfy it — [["team_lead"],["security","compliance"]] means "a team lead, then either security or compliance". Ordering is the control: level 2 cannot decide before level 1.
-H "Authorization: Bearer $TOKEN"
"pattern":"aws:111111:role/prod-*:assume",
"levels":[["team_lead"],["security","compliance"]],
"provisioned":false
}]}
Create or replace the chain for one entitlement pattern. A level is admitted when at least one of its roles holds an approve permission overlapping the pattern; a level no role could ever satisfy is rejected rather than saved as a chain that can never complete.
| Field | Type | Notes |
|---|---|---|
| pattern | string | Required. An entitlement pattern; * matches within a segment. Most-specific pattern wins at request time. |
| levels | string[][] | Required, non-empty, max 5 levels. Always nested — one shape, even for a single-role level. Roles within a level are deduplicated and stored sorted, since a set has no order. |
-H "Authorization: Bearer $TOKEN" \
-d '{"pattern":"aws:111111:role/prod-*:assume","levels":[["team_lead"],["security","compliance"]]}'
"levels":[["team_lead"],["security","compliance"]],
"provisioned":false}
409 {"error":"approval chain: provisioned from config and not editable here"}
policy.yaml returns 409. The file is the source of truth for what it declares; editing it here would create two answers to "who approves this".Remove the chain for a pattern, passed as ?pattern=. Requests matching it then fall back to the next-most-specific chain, or to the policy default — never to "no approval needed" by accident.
-H "Authorization: Bearer $TOKEN"
409 {"error":"approval chain: provisioned from config and not editable here"}
Roles & bindings
The RBAC catalog and who holds what. Available in DB mode; in config mode the roles come from policy.yaml and these routes are absent.
The role catalog: each role's entitlement permissions, the resources it manages, and the resources it may only view. Reads are gated, not just writes — the binding graph discloses who holds admin, which is exactly what an attacker enumerates first.
-H "Authorization: Bearer $TOKEN"
"name":"oncall","description":"Pager rotation",
"is_admin":false,
"permissions":[{"verb":"approve",
"entitlement":"aws:111111:role/*:assume"}],
"manages":[],"views":["reconciliation"]
}]}
Create a role. is_admin is a blanket grant over every managed resource and every verb — the finer-grained manages/views lists exist so it rarely has to be used.
| Field | Type | Notes |
|---|---|---|
| description | string | Free text. |
| is_admin | bool | Authorized for every verb and every managed resource. Prefer manages. |
| permissions | object[] | {verb, entitlement}. Verbs: view, request, approve, revoke. The entitlement may be a pattern. |
| manages | string[] | Managed resources this role may change. See Schemas. |
| views | string[] | Managed resources this role may only read. Management already implies view. |
-H "Authorization: Bearer $TOKEN" \
-d '{"description":"Pager rotation","views":["reconciliation"]}'
409 {"error":"role: already exists"}
Replace a role's definition wholesale. Not a patch: omitting permissions removes them, which is the safer default — a partial update that silently keeps an old grant is how privilege outlives the decision to remove it. The audit event records the before and after of every field.
-H "Authorization: Bearer $TOKEN" \
-d '{"description":"Pager rotation","is_admin":false}'
Delete a role. Bindings to it go with it, so anyone who held authority only through this role loses it immediately.
-H "Authorization: Bearer $TOKEN"
The whole actor-to-role graph, one entry per actor.
-H "Authorization: Bearer $TOKEN"
{"actor":"alice@example.com","roles":["oncall","requester"]}
]}
The roles held by one actor. Distinct caller and target: asking about yourself and asking about someone else are different questions, and both are checked.
-H "Authorization: Bearer $TOKEN"
Bind an actor to a role. Idempotent — binding twice is not an error.
-H "Authorization: Bearer $TOKEN"
404 {"error":"role: not found"}
Unbind an actor from a role. Takes effect on the next authorization check, not at the next login — authority is read per request, so removal is immediate.
-H "Authorization: Bearer $TOKEN"
Delegations
Hand your approval authority to someone else, for a bounded window. Time-bounded like everything else here.
Delegate your own approval authority over a scope until an expiry. The delegator is always the caller — you cannot delegate authority you do not hold, and you cannot delegate on someone else's behalf. An approval made under a delegation records both the delegate and the delegator in the audit trail, so accountability is not laundered through it.
| Field | Type | Notes |
|---|---|---|
| delegate | string | Required. Who receives the authority. Must not be the caller. |
| scope | string | Required. An entitlement pattern bounding what may be approved. |
| expires_at | string | Required, RFC 3339, in the future. There is no unbounded delegation. |
-H "Authorization: Bearer $TOKEN" \
-d '{"delegate":"bob@example.com","scope":"aws:111111:role/prod-*:assume","expires_at":"2026-08-05T09:00:00Z"}'
"id":"d_41c9","delegator":"alice@example.com",
"delegate":"bob@example.com",
"scope":"aws:111111:role/prod-*:assume",
"created_at":"2026-07-29T09:00:00Z",
"expires_at":"2026-08-05T09:00:00Z","revoked":false
}
Delegations visible to the caller — those they granted and those they hold. Expired and revoked ones are returned too, with their end state, because "who could approve this last Tuesday" is an audit question.
-H "Authorization: Bearer $TOKEN"
"id":"d_41c9","delegator":"alice@example.com",
"delegate":"bob@example.com","revoked":true,
"revoked_by":"alice@example.com",
"revoked_at":"2026-07-30T11:00:00Z"
}]}
End a delegation early. The revoker is recorded — a delegation is never revoked anonymously. Revoking one already revoked is a 400, not a silent success.
-H "Authorization: Bearer $TOKEN"
404 {"error":"delegation: not found"}
Integrations
Register and list vendor provider integrations. Config carries provider-specific fields including secret env:// references — never echoed back in a response.
Register a provider integration. The config blob is validated by the provider's adapter (e.g. GitHub App fields); secrets inside it are env://NAME references resolved at use time, not raw values.
| Field | Type | Notes |
|---|---|---|
| provider | string | Required. Must match a registered adapter, e.g. github. |
| config | object | Required. Provider-specific; see Providers in the docs. |
-H "Authorization: Bearer $TOKEN" \
-d '{"provider":"github",
"config":{"app_id":123456,"installation_id":789012,
"private_key_ref":"env://GITHUB_APP_KEY"}}'
"created_at":"2026-07-15T09:00:00Z"}
# note: 'config' is never echoed back (it may carry secret refs).
List registered integrations. Returns provider and created_at only — never config.
-H "Authorization: Bearer $TOKEN"
"created_at":"2026-07-15T09:00:00Z"}]}
Revoke a registered integration. Keyed on the composite (provider, name), not on provider alone — one broker routinely fronts several accounts of the same vendor, and keying on provider would make removing one silently destroy the others. Revoking one that was never registered is a 404, not a silent success.
-H "Authorization: Bearer $TOKEN"
Secret managers
Where lanyard reads every vendor credential from. Gated separately from integrations: repointing lanyard at a different vault affects the credentials of every integration at once.
The registered backends. Only the address and its labels are returned — a secret manager record stores where to look, never what is found there.
-H "Authorization: Bearer $TOKEN"
"id":"sm_1","provider":"vault","name":"Vault — prod",
"endpoint":"https://vault.acme.internal",
"labels":["production"],
"created_at":"2026-07-20T10:00:00Z"
}]}
Register a backend. Keyed by its own id rather than by provider, so several instances of one vendor — one per environment or account — coexist instead of overwriting each other.
| Field | Type | Notes |
|---|---|---|
| provider | string | Required. The vendor, e.g. vault. |
| name | string | Required. Human label, unique per deployment. |
| endpoint | string | Required. The address to read from. Never a credential. |
| description | string | Optional free text. |
| labels | string[] | Optional. Used for filtering in the console. |
-H "Authorization: Bearer $TOKEN" \
-d '{"provider":"vault","name":"Vault — prod","endpoint":"https://vault.acme.internal"}'
"endpoint":"https://vault.acme.internal",
"created_at":"2026-07-20T10:00:00Z"}
Remove a backend. Integrations that resolved their credentials through it stop being able to, which surfaces as provider failures on the next sweep rather than as silent success.
-H "Authorization: Bearer $TOKEN"
Enrollments
How an identity gets a console password without anyone else ever learning it.
Issue a single-use, expiring token authorizing exactly one identity to set its own password. Gated on credentials, deliberately distinct from bindings: binding a role decides what an identity may do, while enrolling bootstraps how it authenticates at all.
-H "Authorization: Bearer $TOKEN" \
-d '{"actor":"carol@example.com"}'
Redeem an enrollment token by setting a password. Unauthenticated by necessity — the whole point is that the identity has no credential yet. The token is single-use and expiring, and the password is hashed before it touches storage, so nobody else ever learns it.
-d '{"token":"7f3c…","password":"correct-horse-battery-staple"}'
401 {"error":"enrollment: token is not valid"}
# Unknown, expired and already-redeemed collapse into one answer.
Audit
The append-only, per-caller-visible record of every access event.
Read the audit trail visible to the caller. Every request, approval, revoke, expiry, role change, and login outcome emits an immutable event answering who, what, when, where, how and why — plus the before-and-after of anything it changed. Events are returned newest-first and cursor-paginated: pass the next_cursor from one page as ?cursor= to get the next, and ?limit= to size it.
-H "Authorization: Bearer $TOKEN"
{"id":"audit_1","action":"granted","actor":"bob@example.com",
"subject":{"kind":"grant","id":"g_8f3a",
"entitlement":"aws:111111:role/prod-admin:assume"},
"context":{"channel":"api","source_ip":"203.0.113.7",
"request_id":"a1b2c3d4e5f60718"},
"outcome":"success","occurred_at":"2026-07-15T09:05:00Z"},
{"id":"audit_2","action":"role_updated","actor":"admin@example.com",
"subject":{"kind":"role","id":"oncall"},
"context":{"channel":"api","source_ip":"203.0.113.9"},
"changes":[
{"field":"permissions_added","old":"",
"new":"approve:aws:111111:role/*:assume"}],
"outcome":"success","occurred_at":"2026-07-15T09:00:00Z"},
{"id":"audit_3","action":"login_succeeded","actor":"alice@example.com",
"subject":{"kind":"session","id":"alice@example.com"},
"context":{"channel":"api","source_ip":"198.51.100.4"},
"outcome":"success","occurred_at":"2026-07-15T08:58:00Z"}
],"next_cursor":"eyJrIjoi…"}
subject — the object it acted on. Only kind:"grant" subjects carry an entitlement; the rest identify a role, a binding, a chain pattern, an integration, or an identity. context.source_ip is omitted when the origin was not attributable, never guessed.Recompute the audit trail's hash chain end to end and report whether it is intact. Each event is linked to its predecessor, so an event that was altered or removed after the fact breaks the chain at that point and broken_id names where. This is an operator action over the whole trail, which is why it needs audit authority while reading individual events only needs to be able to see them.
-H "Authorization: Bearer $TOKEN"
# A broken chain is still a 200 — the check ran and answered.
200 {"intact":false,"verified":9310,
"broken_id":"audit_9311",
"reason":"hash does not match recorded previous"}
Reconciliation
Whether lanyard is still enforcing time-bounded access — the question that has no answer if the sweep quietly stops.
The reconciliation sweep's heartbeat, the grants it currently cannot reconcile, and recently resolved incidents. The sweep is what revokes access when a grant expires; if it stops, nothing else reclaims that access, and until this endpoint existed its only trace was a line on stderr.
Read last_run and open together. An empty open means "nothing is wrong" only when last_run is recent and fully_recorded; a null last_run means no sweep has ever completed, which is a different thing from a clean one.
| Field | Type | Notes |
|---|---|---|
| last_run | object|null | null until a sweep completes. Never conflate with an empty problem list. |
| last_run.fully_recorded | bool | false when the sweep could not persist part of what it observed. Recording is allowed to fail so that an observability outage never suppresses a revoke — but a run reporting this must never be rendered as healthy. |
| last_run.record_failures | int | How many observations were lost. The problem list may be short by up to this many. |
| last_run.denied | int | Requests the sweep terminated because nobody decided them inside LANYARD_PENDING_TTL. Counted apart from expired: access expiring on schedule is the system working, requests going unanswered is not. |
| interval_seconds | int | The configured sweep period. Judge staleness against this, not a fixed threshold — a deployment sweeping daily is not broken. |
| open | object[] | One entry per grant that cannot currently be reconciled, carrying the verbatim provider error. Always an array, never null. |
| recently_closed | object[] | Resolved incidents, newest first. |
-H "Authorization: Bearer $TOKEN"
"last_run":{"started_at":"2026-07-29T15:38:40Z",
"completed_at":"2026-07-29T15:38:47Z",
"scanned":12,"expired":1,"repaired":0,
"denied":3,"failed":2,
"record_failures":0,"fully_recorded":true},
"interval_seconds":60,
"open":[{"id":"rt_a0b0","grant_id":"a2ee4039",
"kind":"opened",
"entitlement":"github:main:repo/acme/api:admin",
"actor":"admin@example.com",
"error":"actor admin@example.com has no mapped github login (denied)",
"occurred_at":"2026-07-29T11:20:00Z"}],
"recently_closed":[]
}
Schemas & enums
The shapes returned across the API, gathered in one place.
Grant object
| Field | Type | Present when |
|---|---|---|
| id | string | always |
| status | enum | always — see below |
| entitlement | string | always |
| requester | string | always — server-derived from token |
| reason | string | always |
| duration_seconds | int | always |
| requested_at | time | always (RFC 3339, UTC) |
| approved_by | string | active / revoked / expired |
| activated_at | time | active / revoked / expired |
| expires_at | time | active / revoked / expired |
| revoked_by | string | revoked |
| revoked_reason | string | revoked (if provided) |
| revoked_at | time | revoked |
Audit event object
One event answers all six questions: actor (who), subject (what), occurred_at (when), context (where and how), reason (why), and changes (what each altered value became).
| Field | Type | Present when |
|---|---|---|
| id | string | always |
| action | enum | always — see below |
| actor | string | always — who performed the action |
| subject | object | always — what was acted on |
| subject.kind | enum | grant · role · binding · chain · integration · secret_manager · credential · delegation · session |
| subject.id | string | always — the object's identifier within its kind |
| subject.entitlement | string | kind:"grant" only |
| context | object | always — where the action came from |
| context.channel | enum | always — api · console · cli · system |
| context.source_ip | string | when attributable; omitted otherwise, never guessed |
| context.request_id | string | correlates to the server access log |
| context.on_behalf_of | string | when acting under a delegation |
| context.via_role | string | which role satisfied a multi-role approval level |
| outcome | enum | always — success / failure / denied |
| reason | string | the human justification, or the cause of a denial |
| changes | array | when the action altered a recorded value |
| changes[].field | string | the field that changed |
| changes[].old | string | value before; empty if the field was absent |
| changes[].new | string | value after; empty if the field is now absent |
| occurred_at | time | always (RFC 3339, UTC) |
Integration object
Only provider and created_at are ever returned — config is never echoed, since it may carry env:// secret references. Provider is the natural key (no separate id).
Grant status enum
pending → awaiting human approval, or about to be auto-approved. active → approved and provisioned, time-boxed by expires_at. expired → reconciler-ended at expires_at. revoked → ended early by a revoke call. Both expired and revoked are terminal.
Audit action enum
Actions are only ever appended, never renumbered or removed — the tamper-evident hash chain encodes each one, so a change would invalidate every event recorded before it.