Try it live not connected
HTTP API reference

The lanyard REST API, end to end.

Every shipped endpoint — methods, request and response schemas, status codes, and copy-pasteable examples. The same surface the CLI uses; nothing is hidden behind a private route.

01 · Foundations

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.

Every code example on this page is live and editable. Set your server URL (and token, once you have one) in the Try it live bar at the top — $LANYARD_SERVER and $TOKEN in the examples update instantly. Edit on any endpoint opens its request as a form: change the path, the query string, the JSON body, then Run it and read the actual response. A successful 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.
# 1. exchange actor + password for a session token (the only body-authed endpoint)
$ 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_..."
The token is the identity. lanyard never trusts an 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.
Sessions live for 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.
The login body takes an optional 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.
02 · Foundations

Conventions

A few rules hold across the whole API. Knowing them up front makes every endpoint below predictable.

AspectRule
Base URLWhatever LANYARD_ADDR is set to on the server (default http://localhost:8080). Set LANYARD_SERVER on the CLI to match.
Content-Typeapplication/json for every request with a body and every response that has one. POST /auth/logout returns 204 No Content.
Unknown fieldsRejected with 400. Request bodies are decoded with DisallowUnknownFields — typos can't silently be ignored.
IdentityAlways from the resolved token, never the body. See Authentication.
VisibilityEvery 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".
IdempotencyReads 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.
CORSOff 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.

# the chain the defaults already encode
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
Your server URL, token, variables and edits are kept in this browser's 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.

403 {"error":"policy: access request denied"}
500 {"error":"internal error"}

Status codes

200OK — read or transition succeeded
201Created — new grant or integration
204No Content — logout succeeded
400Bad Request — invalid input or illegal transition
401Unauthorized — missing/invalid token or bad login
403Forbidden — RBAC or policy denied
404Not Found — grant not visible to you
500Internal — never leaked; fails closed
03 · Endpoints

Auth

Issue and revoke the session tokens used for every other call.

POST/auth/loginbody only

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).

Request body
FieldTypeNotes
actorstringRequired. Email-like identity declared in policy.yaml.
passwordstringRequired. Compared with the bcrypt token_ref-resolved hash.
$ curl -X POST $LANYARD_SERVER/auth/login \
  -H "Content-Type: application/json" \
  -d '{"actor":"alice@example.com","password":"hunter2"}'
200 {"token":"sess_8f3a..."}

# use this token as: Authorization: Bearer sess_8f3a...
400 {"error":"actor: contains disallowed characters"}
401 {"error":"authenticator: invalid credentials"}
POST/auth/logoutbearer

Revokes the caller's own session. The token itself is the credential — no body, no path id. Subsequent calls with that token fail 401.

$ curl -X POST $LANYARD_SERVER/auth/logout \
  -H "Authorization: Bearer $TOKEN"
204 # no body
401missing/invalid bearer
500internal
03 · Endpoints

Identity

What the caller may see and change. The console asks this before it renders anything.

GET/mebearer

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.

$ curl $LANYARD_SERVER/me \
  -H "Authorization: Bearer $TOKEN"
200 {
  "actor":"alice@example.com",
  "views":["audit","roles","reconciliation"],
  "manages":["roles"]
}
Fails closed per resource: if an authority check errors, that resource is omitted rather than the whole answer failing. Showing a page that the API would then refuse is the worse outcome.
03 · Endpoints

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.

POST/grantsbearer

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.

Request body
FieldTypeNotes
entitlementstringRequired. 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.
reasonstringRequired, non-empty, not a self-approval of a vague string; bounded length.
duration_secondsintRequired, > 0, and ≤ the entitlement's max_duration.
$ curl -X POST $LANYARD_SERVER/grants \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"entitlement":"aws:111111:role/prod-admin:assume","reason":"INC-4821","duration_seconds":7200}'
201 {
  "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"
}
400 {"error":"duration: exceeds maximum grant duration"}
403 {"error":"policy: access request denied"}
# 403 is also returned when the caller lacks the 'request' verb for this entitlement.
201Created — pending or auto-approved
400bad input
401bad token
403RBAC / policy deny
GET/grantsbearer

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.

Query parameters
ParamTypeNotes
statusenumOptional. One of pending · active · expired · revoked. Absent = all visible.
$ curl "$LANYARD_SERVER/grants?status=active" \
  -H "Authorization: Bearer $TOKEN"
200 [{
  "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"
}]
GET/grants/{id}bearer

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.

Path parameters
ParamTypeNotes
idstringRequired. Grant id, e.g. g_8f3a.
$ curl $LANYARD_SERVER/grants/g_8f3a \
  -H "Authorization: Bearer $TOKEN"
200 {"id":"g_8f3a","status":"active","entitlement":"aws:111111:role/prod-admin:assume",
  "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.

200OK
401bad token
404not found / not visible
POST/grants/{id}/approvebearer

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.

$ curl -X POST $LANYARD_SERVER/grants/g_8f3a/approve \
  -H "Authorization: Bearer $TOKEN"
200 {"id":"g_8f3a","status":"active",
  "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"}
  ]}}
400 {"error":"grant: invalid state transition"}
# non-pending grants (active/expired/revoked) reject approve
403 {"error":"grant: actor is not authorized..."}
# also returned for self-approval attempts.
200OK — now active
400illegal transition
403not authorized / self-approval
404not visible
POST/grants/{id}/revokebearer

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.

Request body
FieldTypeNotes
reasonstringOptional. Recorded in the audit event.
$ curl -X POST $LANYARD_SERVER/grants/g_8f3a/revoke \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"reason":"incident closed"}'
200 {"id":"g_8f3a","status":"revoked",
  "revoked_by":"bob@example.com",
  "revoked_reason":"incident closed",
  "revoked_at":"2026-07-15T09:40:00Z"}
200OK — now revoked
400already terminal
403not authorized
404not visible
03 · Endpoints

Catalog

What is available to request, and the shapes a request form is built from.

GET/catalogbearer

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.

$ curl $LANYARD_SERVER/catalog?q=prod \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[
  {"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.

A provider that cannot be reached is an error, never a silently short list — a broker that cannot see the true catalog must say so rather than let an operator believe an empty result is complete.
GET/catalog/templatesbearer

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.

$ curl $LANYARD_SERVER/catalog/templates \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[{
  "pattern":"github:acme:repo/*:admin",
  "name":"GitHub repository — admin",
  "description":"Full control of one repository",
  "params":[{"name":"repo","label":"Repository"}]
  }]}
GET/catalog/templates/optionsbearer

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.

$ curl $LANYARD_SERVER/catalog/templates/options?pattern=github:acme:repo/*:admin \
  -H "Authorization: Bearer $TOKEN"
200 {"params":[
  {"name":"repo","label":"Repository",
   "options":["acme/api","acme/web"]}
  ]}
03 · Endpoints

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.

GET/approval-chainsbearerchains

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.

$ curl $LANYARD_SERVER/approval-chains \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[{
  "pattern":"aws:111111:role/prod-*:assume",
  "levels":[["team_lead"],["security","compliance"]],
  "provisioned":false
  }]}
PUT/approval-chainsbearerchains

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.

Request body
FieldTypeNotes
patternstringRequired. An entitlement pattern; * matches within a segment. Most-specific pattern wins at request time.
levelsstring[][]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.
$ curl -X PUT $LANYARD_SERVER/approval-chains \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"pattern":"aws:111111:role/prod-*:assume","levels":[["team_lead"],["security","compliance"]]}'
200 {"pattern":"aws:111111:role/prod-*:assume",
 "levels":[["team_lead"],["security","compliance"]],
 "provisioned":false}
400 {"error":"approval chain: no role at this level can approve the pattern"}
409 {"error":"approval chain: provisioned from config and not editable here"}
A chain provisioned from 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".
DELETE/approval-chainsbearerchains

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.

$ curl -X DELETE $LANYARD_SERVER/approval-chains?pattern=aws:111111:role/prod-*:assume \
  -H "Authorization: Bearer $TOKEN"
404 {"error":"approval chain: not found"}
409 {"error":"approval chain: provisioned from config and not editable here"}
03 · Endpoints

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.

GET/rolesbearerroles

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.

$ curl $LANYARD_SERVER/roles \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[{
  "name":"oncall","description":"Pager rotation",
  "is_admin":false,
  "permissions":[{"verb":"approve",
   "entitlement":"aws:111111:role/*:assume"}],
  "manages":[],"views":["reconciliation"]
  }]}
POST/roles/{name}bearerroles

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.

Request body
FieldTypeNotes
descriptionstringFree text.
is_adminboolAuthorized for every verb and every managed resource. Prefer manages.
permissionsobject[]{verb, entitlement}. Verbs: view, request, approve, revoke. The entitlement may be a pattern.
managesstring[]Managed resources this role may change. See Schemas.
viewsstring[]Managed resources this role may only read. Management already implies view.
$ curl -X POST $LANYARD_SERVER/roles/oncall \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"description":"Pager rotation","views":["reconciliation"]}'
400 {"error":"managed resource \"reconciiation\": invalid managed resource"}
409 {"error":"role: already exists"}
PUT/roles/{name}bearerroles

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.

$ curl -X PUT $LANYARD_SERVER/roles/oncall \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"description":"Pager rotation","is_admin":false}'
404 {"error":"role: not found"}
DELETE/roles/{name}bearerroles

Delete a role. Bindings to it go with it, so anyone who held authority only through this role loses it immediately.

$ curl -X DELETE $LANYARD_SERVER/roles/oncall \
  -H "Authorization: Bearer $TOKEN"
404 {"error":"role: not found"}
GET/roles/bindingsbearerbindings

The whole actor-to-role graph, one entry per actor.

$ curl $LANYARD_SERVER/roles/bindings \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[
  {"actor":"alice@example.com","roles":["oncall","requester"]}
  ]}
GET/actors/{actor}/rolesbearerbindings

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.

$ curl $LANYARD_SERVER/actors/alice@example.com/roles \
  -H "Authorization: Bearer $TOKEN"
200 {"actor":"alice@example.com","roles":["oncall"]}
POST/roles/{name}/bindings/{actor}bearerbindings

Bind an actor to a role. Idempotent — binding twice is not an error.

$ curl -X POST $LANYARD_SERVER/roles/oncall/bindings/alice@example.com \
  -H "Authorization: Bearer $TOKEN"
400 {"error":"actor: invalid identity"}
404 {"error":"role: not found"}
DELETE/roles/{name}/bindings/{actor}bearerbindings

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.

$ curl -X DELETE $LANYARD_SERVER/roles/oncall/bindings/alice@example.com \
  -H "Authorization: Bearer $TOKEN"
404 {"error":"role binding: not found"}
03 · Endpoints

Delegations

Hand your approval authority to someone else, for a bounded window. Time-bounded like everything else here.

POST/delegationsbearer

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.

Request body
FieldTypeNotes
delegatestringRequired. Who receives the authority. Must not be the caller.
scopestringRequired. An entitlement pattern bounding what may be approved.
expires_atstringRequired, RFC 3339, in the future. There is no unbounded delegation.
$ curl -X POST $LANYARD_SERVER/delegations \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"delegate":"bob@example.com","scope":"aws:111111:role/prod-*:assume","expires_at":"2026-08-05T09:00:00Z"}'
201 {
  "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
}
GET/delegationsbearer

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.

$ curl $LANYARD_SERVER/delegations \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[{
  "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"
  }]}
POST/delegations/{id}/revokebearer

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.

$ curl -X POST $LANYARD_SERVER/delegations/d_41c9/revoke \
  -H "Authorization: Bearer $TOKEN"
400 {"error":"delegation: already revoked"}
404 {"error":"delegation: not found"}
03 · Endpoints

Integrations

Register and list vendor provider integrations. Config carries provider-specific fields including secret env:// references — never echoed back in a response.

POST/integrationsbearer

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.

Request body
FieldTypeNotes
providerstringRequired. Must match a registered adapter, e.g. github.
configobjectRequired. Provider-specific; see Providers in the docs.
$ curl -X POST $LANYARD_SERVER/integrations \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"provider":"github",
  "config":{"app_id":123456,"installation_id":789012,
  "private_key_ref":"env://GITHUB_APP_KEY"}}'
201 {"provider":"github",
  "created_at":"2026-07-15T09:00:00Z"}

# note: 'config' is never echoed back (it may carry secret refs).
201Created
400unsupported provider / validation
401bad token
GET/integrationsbearer

List registered integrations. Returns provider and created_at only — never config.

$ curl $LANYARD_SERVER/integrations \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[{"provider":"github","name":"acme",
 "created_at":"2026-07-15T09:00:00Z"}]}
The stored config is never returned — only that the integration exists and when it was registered. Credentials go in, nothing comes back out.
DELETE/integrations/{provider}/{name}bearerintegrations

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.

$ curl -X DELETE $LANYARD_SERVER/integrations/github/acme \
  -H "Authorization: Bearer $TOKEN"
404 {"error":"integration: not found"}
Grants already active at that provider are not revoked by this. Removing the integration removes lanyard's ability to reach it — including its ability to revoke on expiry, which the reconciliation page will then start reporting.
03 · Endpoints

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.

GET/secret-managersbearersecret_managers

The registered backends. Only the address and its labels are returned — a secret manager record stores where to look, never what is found there.

$ curl $LANYARD_SERVER/secret-managers \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[{
  "id":"sm_1","provider":"vault","name":"Vault — prod",
  "endpoint":"https://vault.acme.internal",
  "labels":["production"],
  "created_at":"2026-07-20T10:00:00Z"
  }]}
POST/secret-managersbearersecret_managers

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.

Request body
FieldTypeNotes
providerstringRequired. The vendor, e.g. vault.
namestringRequired. Human label, unique per deployment.
endpointstringRequired. The address to read from. Never a credential.
descriptionstringOptional free text.
labelsstring[]Optional. Used for filtering in the console.
$ curl -X POST $LANYARD_SERVER/secret-managers \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"provider":"vault","name":"Vault — prod","endpoint":"https://vault.acme.internal"}'
201 {"id":"sm_1","provider":"vault","name":"Vault — prod",
 "endpoint":"https://vault.acme.internal",
 "created_at":"2026-07-20T10:00:00Z"}
DELETE/secret-managers/{id}bearersecret_managers

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.

$ curl -X DELETE $LANYARD_SERVER/secret-managers/sm_1 \
  -H "Authorization: Bearer $TOKEN"
404 {"error":"secret manager: not found"}
03 · Endpoints

Enrollments

How an identity gets a console password without anyone else ever learning it.

POST/enrollmentsbearercredentials

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.

$ curl -X POST $LANYARD_SERVER/enrollments \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"actor":"carol@example.com"}'
201 {"token":"7f3c…"}
The raw token is returned once and never stored in plaintext. Hand it to the identity over a channel you trust; there is no way to read it back.
POST/enrollments/redeemnone

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.

$ curl -X POST $LANYARD_SERVER/enrollments/redeem \
  -d '{"token":"7f3c…","password":"correct-horse-battery-staple"}'
200 {"actor":"carol@example.com"}
400 {"error":"password: too short"}
401 {"error":"enrollment: token is not valid"}
# Unknown, expired and already-redeemed collapse into one answer.
03 · Endpoints

Audit

The append-only, per-caller-visible record of every access event.

GET/auditbearer

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.

$ curl $LANYARD_SERVER/audit \
  -H "Authorization: Bearer $TOKEN"
200 {"items":[
  {"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…"}
Every event names its 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.
GET/audit/verifybeareraudit

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.

$ curl $LANYARD_SERVER/audit/verify \
  -H "Authorization: Bearer $TOKEN"
200 {"intact":true,"verified":18422}

# 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"}
Tamper-evident, not tamper-proof: this detects alteration, it does not prevent it. Someone with write access to the database can still rewrite history — they just cannot do it without this saying so.
03 · Endpoints

Reconciliation

Whether lanyard is still enforcing time-bounded access — the question that has no answer if the sweep quietly stops.

GET/reconciliationbearerreconciliation

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.

Response
FieldTypeNotes
last_runobject|nullnull until a sweep completes. Never conflate with an empty problem list.
last_run.fully_recordedboolfalse 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_failuresintHow many observations were lost. The problem list may be short by up to this many.
last_run.deniedintRequests 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_secondsintThe configured sweep period. Judge staleness against this, not a fixed threshold — a deployment sweeping daily is not broken.
openobject[]One entry per grant that cannot currently be reconciled, carrying the verbatim provider error. Always an array, never null.
recently_closedobject[]Resolved incidents, newest first.
$ curl $LANYARD_SERVER/reconciliation \
  -H "Authorization: Bearer $TOKEN"
200 {
  "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":[]
}
Read-only by design. There is no "sweep now": at a one-minute default it would shorten nothing worth shortening, while handing any authorized operator a repeatable amplifier against a provider's rate limit.
04 · Reference

Schemas & enums

The shapes returned across the API, gathered in one place.

Grant object

FieldTypePresent when
idstringalways
statusenumalways — see below
entitlementstringalways
requesterstringalways — server-derived from token
reasonstringalways
duration_secondsintalways
requested_attimealways (RFC 3339, UTC)
approved_bystringactive / revoked / expired
activated_attimeactive / revoked / expired
expires_attimeactive / revoked / expired
revoked_bystringrevoked
revoked_reasonstringrevoked (if provided)
revoked_attimerevoked

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).

FieldTypePresent when
idstringalways
actionenumalways — see below
actorstringalways — who performed the action
subjectobjectalways — what was acted on
subject.kindenumgrant · role · binding · chain · integration · secret_manager · credential · delegation · session
subject.idstringalways — the object's identifier within its kind
subject.entitlementstringkind:"grant" only
contextobjectalways — where the action came from
context.channelenumalways — api · console · cli · system
context.source_ipstringwhen attributable; omitted otherwise, never guessed
context.request_idstringcorrelates to the server access log
context.on_behalf_ofstringwhen acting under a delegation
context.via_rolestringwhich role satisfied a multi-role approval level
outcomeenumalways — success / failure / denied
reasonstringthe human justification, or the cause of a denial
changesarraywhen the action altered a recorded value
changes[].fieldstringthe field that changed
changes[].oldstringvalue before; empty if the field was absent
changes[].newstringvalue after; empty if the field is now absent
occurred_attimealways (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 active expired revoked

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.

requested granted revoked expired denied approval_recorded approval_level_skipped notification_failed login_succeeded login_failed role_created role_updated role_deleted role_bound role_unbound chain_created chain_updated chain_deleted integration_registered integration_revoked secret_manager_registered secret_manager_removed delegation_created delegation_revoked enrollment_issued credential_set