Documentation

Everything lanyard does, today.

A working reference for the parts that are actually shipped — install, configure a policy, and drive the grant lifecycle from the CLI or the HTTP API. No roadmap items, no "coming soon."

01 · Start here

Getting started

lanyard is a single binary that is both the API server and the CLI client. Install it with Homebrew or curl, then start the server — or run the full stack (PostgreSQL + lanyard) with docker compose up, no toolchain needed.

# macOS / Linux — Homebrew
$ brew install EmiraLabs/lanyard/lanyard

# any Unix — one-shot install script (puts lanyard on your PATH)
$ curl -fsSL https://github.com/EmiraLabs/lanyard/releases/latest/download/install.sh | sh
→ > lanyard --help
# run the full stack (PostgreSQL + lanyard) in one command
$ docker run --rm -p 8080:8080 emiralabs/lanyard:latest serve
→ lanyard on :8080
# for the bundled profile with Postgres, clone the repo and use docker compose up
# secrets come from .env at runtime and fail fast when missing — the server never starts half-configured
# copy the example policy and edit it: identities, roles, entitlements
$ cp policy.example.yaml policy.yaml
# credentials are never written into policy.yaml directly —
# each token_ref points at an env var, resolved at startup
$ export LANYARD_TOKEN_ALICE="..."
# start the API server (reads LANYARD_POLICY_FILE, defaults to policy.yaml)
$ ./bin/lanyard serve
→ listening on :8080
# the same binary is the client — log in, then request access
$ lanyard login alice@example.com
Password: **** → session saved locally

$ lanyard request --entitlement github:acme:repo/acme/api:push \
  --reason "debug INC-4821" --for 1h
✓ requested · g_8f3a · active (auto-approved)
Both front-ends are served by the same binary — the admin console at /console and the requester portal at /portal. Open http://localhost:8080 after lanyard serve to see grants, approvals, reconciliation and the audit trail in a browser.
02 · Concepts

How a grant moves

Every access grant follows the same lifecycle, whichever door it came through — CLI, API, or console.

pendingactiveexpired or revoked

A request is created pending. The policy engine either auto-approves it or leaves it for a human with the approve verb. Once approved it becomes active for its requested duration, then the reconciler transitions it to expired on its own — or a revoker ends it early, moving it straight to revoked. Both are terminal; a grant never outlives its expires_at while still reporting itself active.

Entitlements

The unit of access is always four segments — provider:instance:resource:capability, e.g. github:acme:repo/acme/api:push. There is no shorthand: instance names which org, account or cluster, so a broker fronting two of them can never resolve a grant ambiguously.

RBAC

Identities and roles are loaded from the policy file. A role is a list of (verb, entitlement) pairs — verb is one of request, approve, revoke — plus optional role_management / role_view authority over non-entitlement resources.

Policy & approval chains

max_duration caps every request and auto_approve skips the human step. Anything else runs an ordered approval_chain: levels are AND, the roles inside one level are OR, and a level can be satisfied by an active delegation.

Reconciler

Runs on LANYARD_RECONCILE_INTERVAL, comparing desired state against the provider, repairing drift, and expiring grants on time even across a restart. Each sweep is recorded durably, so a stopped sweep never reads as a clean one.

Every request, approval and revoke emits an audit event answering who, what, when, where, why and how, linked into a tamper-evident hash chain and readable via GET /audit (verify it with GET /audit/verify). On any internal error the system fails closed — deny, never grant.
03 · Reference

CLI

Talks to LANYARD_SERVER (default http://localhost:8080) and authenticates with LANYARD_TOKEN, or the session saved by login.

CommandFlagsDoes
serveRuns the API server (and the console and portal, from the same binary).
init, installInteractive first-run setup wizard.
login <actor>Prompts for a password (hidden input), saves the session token locally.
logoutRevokes the current session and clears the saved credential.
enroll <actor>Issues a single-use token so that actor can set their own password.
passwd--token TRedeems an enrollment token and sets your password.
request--entitlement E --reason R --for DRequests entitlement E for duration D (e.g. 1h, 30m). All three flags are required.
list[--status S]Lists grants visible to you, optionally filtered by pending|active|expired|revoked.
get <id>Shows one grant's full detail.
approve <id>Approves a pending grant. Requires the approve verb on that entitlement, and satisfies one chain level.
revoke <id>[--reason R]Ends an active or pending grant early.
integration add <provider>--name N --config-file FRegisters a vendor integration from a JSON config file.
audit verifyVerifies the audit hash chain against the database directly, not through the API. Exits 0 when intact and 1 on tampering or error, so it can gate a cron job or CI.
db:migrateApplies pending schema migrations.
update, upgradeUpgrades the installed lanyard binary.
Schema and version are separate concerns. db:migrate moves the database forward; update moves the binary. Run db:migrate after an upgrade that ships a migration — and never edit a migration that has already been applied.
03 · Reference

HTTP API

The CLI speaks to lanyard over the same REST surface you can. The full reference — every endpoint, method, request/response schema, status code, and copy-pasteable example — lives on a dedicated page.

HTTP API reference → Authentication · Grants · Integrations · Audit · Schemas. With method chips, curl examples, and the exact JSON shapes the server returns. api.html

In short: every endpoint but /auth/login expects Authorization: Bearer <token>, identity always comes from the resolved token (never the body), unknown fields are rejected with 400, and internal errors return a generic {"error":"internal error"} — full detail is logged server-side, never leaked.

03 · Reference

Configuration

Configuration comes from environment variables, optionally layered over a YAML config file. Every value is bounded — there is no setting that can be widened to "forever".

VariableDefaultPurpose
LANYARD_ADDR:8080Listen address for serve.
LANYARD_CONFIG_FILE~/.config/lanyard/config.yamlYAML config file. Environment variables win over anything set in it.
LANYARD_POLICY_FILEpolicy.yamlIdentities, roles, and policy rules.
LANYARD_MODEself-hostedDeployment mode, self-hosted or cloud. Decides whether lanyard may hold a reference to a long-lived credential it does not own (ADR 0009). Closed to those two values — an unrecognised mode is a startup error, never a silent fallback.
LANYARD_DB_DRIVERsqliteStorage backend: memory, sqlite, or postgres.
LANYARD_DB_DSNlanyard.dbDatabase DSN (required for postgres). Treated as a credential and never logged.
LANYARD_RECONCILE_INTERVAL1mHow often the reconciler checks for drift/expiry. Capped at 24h — the sweep is what enforces expiry, so a long interval widens the window where expired access stays live.
LANYARD_SESSION_TTL12hLifetime of login session tokens. Capped at one year.
LANYARD_SECRET_MANAGERenvSecret backend that resolves every *_ref. env is the only one implemented today.
LANYARD_NOTIFY_WEBHOOKSecret reference to an incoming-webhook URL, e.g. env://SLACK_WEBHOOK_URL. Unset means notifications are off. See Approvals.
LANYARD_LOG_LEVELinfoOne of debug, info, warn, error.
LANYARD_TRUSTED_PROXY_HOPS0How many reverse proxies sit in front of lanyard. Decides how far back in X-Forwarded-For the client IP is read; 0 means the header is not trusted at all.
LANYARD_CORS_ORIGINSOptional CORS allowlist. Empty (the default) means CORS is off. The single token * responds Access-Control-Allow-Origin: *. Otherwise a comma-separated allowlist of exact http(s) origins, e.g. http://localhost:5173,https://docs.example.com. Auth still applies either way — CORS only relaxes the browser's same-origin policy, never authorization.
LANYARD_TOKENClient-side: bearer token, falls back to the session saved by login.
LANYARD_SERVERhttp://localhost:8080Client-side: server URL for CLI commands.
LANYARD_ADMIN_TOKENRead by lanyard init. The generated policy stores only the reference env://LANYARD_ADMIN_TOKEN, never the token itself.

Config file

Anything above that is not a client-side or secret value can live in a YAML file instead, so a deployment does not need a wall of exports. Environment variables always win over the file — that ordering is what lets a container override one setting without rewriting the file.

# ~/.config/lanyard/config.yaml — or point LANYARD_CONFIG_FILE at your own
addr: ":8080"
db_driver: postgres
db_dsn: "postgres://lanyard@localhost:5432/lanyard?sslmode=disable"
policy_file: policy.yaml
reconcile_interval: 1m
session_ttl: 12h
secret_manager: env
cors_origins: ["https://console.example.com"]
A config file that sets an unsupported secret_manager fails at startup rather than falling back to a working one. Failing closed on a misconfigured secret backend is the same rule the grant path follows: on error, deny.
03 · Reference

Policy file

One YAML file declares who exists, what they can do, and how each entitlement is governed. Copy policy.example.yaml to start.

# identities: who can authenticate, which roles they hold, and — for
# providers that need it — the vendor account they map onto
identities:
  - actor: alice@example.com
    token_ref: env://LANYARD_TOKEN_ALICE
    provider_logins:
      github: alice-gh
    roles: [requester, prod_approver]

# roles: a name mapped to (verb, entitlement) permissions — verb is
# one of request | approve | revoke
roles:
  requester:
    - verb: request
      entitlement: "github:acme:repo/acme/api:push"
  prod_approver:
    - verb: approve
      entitlement: "github:acme:repo/acme/api:push"

# management authority over non-entitlement resources — the
# least-privilege alternative to a full admin flag. role_view is the
# read-only half (the auditor case).
role_management:
  platform_admin: [roles, bindings, integrations]
role_view:
  auditor: [audit, roles, bindings]

# entitlements: per-entitlement caps, auto-approval, and the chain.
# Levels are AND; the roles inside one level are OR.
entitlements:
  "github:acme:repo/acme/api:push":
    policy:
      max_duration: 24h
    approval_chain: [[prod_approver], [platform_admin]]
  "github:acme:repo/acme/docs:push":
    policy:
      max_duration: 1h
      auto_approve: true
      auto_approve_duration: 1h
      auto_approve_requesters: [alice@example.com]
Never write a raw secret into this file. Every token_ref / *_ref is a reference, resolved at use — so lanyard stores the pointer, never the key. env://NAME is the only resolver implemented today; vault:// and aws-sm:// are planned.
04 · Operate

Approvals, chains and delegation

Anything policy does not auto-approve runs an ordered approval chain. Levels are AND — every one must be satisfied, in order. The roles inside a single level are OR — any one of them is enough.

entitlements:
  "github:acme:repo/acme/api:push":
    # team_lead first, then security OR compliance
    approval_chain: [[team_lead], [security, compliance]]
RuleBehaviour
LevelsUp to 5, evaluated in order. Each needs one approval from an actor bound to one of its roles and able to approve that entitlement.
QuorumRepeat a level. The same person can never satisfy two levels, so [[sre, security], [sre, security]] means two different people from those teams.
A level never widensThe chain says who approves and in what order; the role's own approve permission says what they may approve. Being named in a level grants no authority the role does not already carry (ADR 0012).
Vacant levelsA level no member of any of its roles can satisfy is skipped upward — but the final level always requires a human, and if every level is vacant only an admin can approve.
Self-approvalRejected. So is approving your own delegated request.

Delegation

A delegation is a time-bounded, pattern-scoped transfer of one actor's approval authority to another — for leave, or on-call handover. It confers nothing by itself: at approval time the delegator must currently hold the approving level's role, so a delegation can never hand over authority the delegator has since lost. It is non-transitive by construction — eligibility never chains through a second delegation — and, like a grant, it must be time-bounded.

$ curl -X POST $LANYARD/delegations \
  -H "Authorization: Bearer $LANYARD_TOKEN" \
  -d '{"delegate":"bob@example.com",
    "scope":"github:acme:repo/acme/*:*",
    "expires_at":"2026-08-05T09:00:00Z"}'
201 {"id":"d_41c9","delegator":"alice@example.com","revoked":false, …}

$ curl -X POST $LANYARD/delegations/d_41c9/revoke # ends it early

The delegator is always the caller — you cannot delegate authority you do not hold, nor delegate on someone else's behalf. expires_at is required and must be in the future: there is no unbounded delegation. An approval made under one records both the delegate and the delegator in the audit trail, so accountability is not laundered through it.

Approver notifications

Set LANYARD_NOTIFY_WEBHOOK to a secret reference and lanyard POSTs a summary of every pending grant to that endpoint. The body is the de-facto incoming-webhook shape ({"text": …}), so it renders in Slack, Discord and Mattermost with no extra configuration, and is readable by any custom consumer. Unset means notifications are simply off.

A notification is not an access control. Delivery is best-effort — a webhook failure never blocks or fails the request that triggered it — and a delivered message carries no approval authority. Approval always happens through the authenticated API or CLI (ADR 0011). The payload is built only from a grant's already-public facts, so there is structurally no field a credential could occupy; the webhook URL itself is treated as a credential and resolved from your secret backend at each use, never held at rest.
04 · Operate

Providers & integrations

Vendor access is granted through pluggable provider adapters behind a router. GitHub App is the only provider that ships today — it covers repository collaborator and organisation team access. Everything else on the landing page's logo strip is planned, not shipped.

FieldTypeNotes
app_idintGitHub App ID, must be positive.
installation_idintInstallation ID for the target org, must be positive.
private_key_refstringSecret reference to the App's RSA private key, e.g. env://GITHUB_APP_KEY.
# github-app.json
{ "app_id": 123456, "installation_id": 789012, "private_key_ref": "env://GITHUB_APP_KEY" }

$ lanyard integration add github --name acme --config-file github-app.json
✓ registered · provider=github · name=acme

One provider can have several integrations, which is why --name is required — a broker fronting two GitHub orgs registers each separately, and the entitlement's instance segment is what picks between them at grant time (ADR 0007). Registering an integration is itself a gated, audited action: it needs management authority over integrations, because an integration points lanyard's credentials at a target.

New providers implement one port — Grant, Revoke, HasAccess, List — and are validated against a shared conformance kit (internal/testutil) before they ship. The kit fatals on an incomplete fixture set, so a check that was skipped can never read as a check that passed.

04 · Operate

Storage

Set LANYARD_DB_DRIVER to choose a backend; all three implement the same repository interfaces.

memory

In-process only — fast, nothing persists across a restart. Good for tests and demos.

sqlite default

A single local file (LANYARD_DB_DSN, default lanyard.db). No external dependency to run.

postgres

Set LANYARD_DB_DSN to a connection string. Recommended once more than one server instance is running.

Migrations and upgrades

Schema and binary move independently. lanyard db:migrate applies pending schema migrations; lanyard update upgrades the binary. Run the migration after an upgrade that ships one (ADR 0016). A fresh database is baselined at its actual version rather than assumed to be at head, so adopting an existing deployment does not skip migrations it still needs.

$ lanyard update
✓ lanyard v0.4.1 → v0.5.0

$ lanyard db:migrate
✓ applied 2 migrations · schema at 0007
Never edit a migration that has already been applied. Add a new one instead — an applied migration is a fact about every database that ran it, and rewriting it makes two deployments claiming the same version structurally different.
04 · Operate

Reconciliation & audit

The two things to watch on a running lanyard: whether enforcement is still happening, and whether the record of what happened is intact. They are deliberately separate — one governs the future, the other the past.

Is enforcement still running?

Every sweep records its outcome durably, so GET /reconciliation can answer whether lanyard is still enforcing expiry — not just whether the last sweep found nothing. This matters because the failure modes look identical from outside: a sweep that ran and found no problems and a sweep that never ran both report zero problems. The status carries when the last sweep completed, what it repaired, and what it could not (ADR 0014). The console surfaces it at /console/reconciliation, and reconciliation is its own role_view resource so someone on call can read it without being able to read the audit trail.

$ curl $LANYARD/reconciliation -H "Authorization: Bearer $LANYARD_TOKEN"
200 {
  "last_run": { "started_at": "…", "completed_at": "…",
    "scanned": 42, "expired": 3, "repaired": 1, "failed": 0,
    "record_failures": 0, "fully_recorded": true },
  "interval_seconds": 60,
  "open": [], "recently_closed": []
}

fully_recorded is served rather than left for you to derive from record_failures. "A sweep that lost observations is never shown as healthy" is a safety property, and deriving it separately in the API and the front-end is exactly how the two come to disagree.

Is the record intact?

Every event is linked into a tamper-evident hash chain, each entry covering the one before it. GET /audit/verify walks the chain and reports the first break, so a deleted or rewritten row is detectable rather than merely unlikely (ADR 0006). Each event answers who, what, when, where, why and how, and for configuration changes it carries the before/after values — a widened role shows exactly what widened (ADR 0015).

# reads the database directly, not the HTTP API — exit 0 if intact,
# 1 on tampering or any error, so it can gate a cron job or CI
$ lanyard audit verify
OK: audit trail intact (1284 event(s) verified)

$ curl $LANYARD/audit/verify -H "Authorization: Bearer $LANYARD_TOKEN"
200 {"intact":true,"verified":1284}
Both endpoints are permission-gated like everything else: audit and reconciliation are separate role_view resources, so an auditor and an on-call engineer get exactly the one they need.