For contributors: where this lives in the source
For contributors: where this lives in the source
- Backend:
internal/api/{auth,users,roles,abilities,iam,iam_handlers,invites,tokens,twofactor,oauth,oauth_settings,device_auth,audit,audit_retention}.go - CLI:
cmd/levelrail-cli/{users,iam,invites,tokens,auth}*.go - Dashboard:
web/src/routes/settings/{users,iam-policies,security,tokens,oauth,cli-access,audit-log}.tsx - OAuth sign-in decision logic:
completeOAuthSigninininternal/api/oauth.go
Common tasks
Bootstrap the first admin
Create a teammate with a curated role
Invite a teammate by email
Scope a CI token down to one app
checkout even without a global deploy ability. To block a resource a broader role would otherwise allow, write an Effect: "Deny" statement instead.Turn on two-factor auth
Log the CLI in with the device-code flow
Review who changed what
Why two permission models instead of one
Most self-hosted platforms pick one shape and live with its downside.- Flat roles (admin/member/viewer) are easy to reason about but cannot express “this CI token may deploy exactly one app and nothing else.”
- A full IAM policy engine can express that, but it is overkill for “give Priya everything, give the contractor read-only.”
root. Everything else falls back to the flat abilities exactly as if no policy existed.
How it actually works
Principals: a session, or a token
Every gated route resolves the request to exactly one principal before deciding anything: a session cookie (mapped to astore.User row) or a bearer token (with its own stored Abilities).
Sessions
Sessions are an in-memory map keyed by an opaque token (sessionStore), with a TTL of 24h by default (overridable with APP_SESSION_TTL, a Go duration string). A restart clears every live session (an accepted tradeoff for a single-node control plane).
Session cookie security
The session cookie’s Secure flag follows the request: it is set when the request arrived over TLS, or through the embedded Caddy ingress (a loopback peer sending X-Forwarded-Proto: https). X-Forwarded-Proto from any other peer is ignored, so a remote client cannot spoof it. A fresh install reached at http://<server-ip>:8080 therefore works out of the box, and the dashboard shows a persistent “connection is not encrypted” banner until you move to HTTPS.
Once you set an https:// dashboard URL (Domains page, levelrail-cli settings dashboard-url set --url https://..., or PUT /api/v1/settings/dashboard-url), sign-in over plain HTTP is refused with a 403 pointing at that URL. This covers password login, 2FA verification, and invite acceptance. An https:// URL can only be saved from a request that already arrived over HTTPS (or from the server itself), so saving it cannot lock you out.
Abilities and roles
signin:approve, which is never implied by any other ability. See The signin:approve ability.
A built-in role sets a principal’s Abilities to one of three fixed sets in one action:
GET /api/v1/roles lists the roles; a user or token row does not remember “I was created via the operator role.” Custom roles are managed with levelrail-cli roles (see Access control). The dashboard and CLI show a role for display by matching a principal’s abilities against the presets; anything that doesn’t match exactly shows as “custom,” which is expected, not an error state.
IAM policies: Allow/Deny over a resource
A policy document uses AWS IAM’s shape, hand-typed or generated:authorizeResource, internal/api/iam.go)
Checked on every mutating or sensitive route scoped to a specific app or database (get, update, delete, deploy, rollback, restart, clone, exec, secrets, tags, domain config, backups, and so on):
- An explicit Deny matching the ability and resource, in any attached policy, always wins, even for a
rootprincipal. - Otherwise, the principal’s own flat abilities decide, unchanged from having no policies attached.
- Otherwise, an explicit Allow in an attached policy can still grant access the flat abilities don’t, scoped to that one resource.
user or a token (principal_type), by that principal’s id. A malformed stored document can never grant or deny anything; it is treated as if it doesn’t mention the pair being checked.
Lists. A Deny policy on app:<name> hides that app from GET /api/v1/apps, apps-summary, apps-metrics, deployments, certificates (including the auto-generated sslip.io hostname), GET /api/v1/network/topology (apps and databases) and the container names in GET /api/v1/system/containers, and returns 403 on every /apps/{name}/... route.
References in a request body or query. The same check applies to a resource a request names outside its path: a database attached by name, a target environment or node, a promotion target, a compose or deploy-spec service name that already belongs to another app, a git source’s additional services, a pipeline step that deploys another app, a standalone domain pointing at an app, or an ?app= filter. A refused reference answers exactly like an unknown one, so the response never confirms that a hidden resource exists. Server-computed fan-outs (environment clone, protect-all, cutover reports, restore drills) skip resources the caller cannot see instead of failing. internal/api/body_ref_authz_guard_test.go fails the build when a handler reads such a field without one of these checks.
Test before you trust. iam simulate answers “can this principal do this on this resource” with the real evaluator, iam effective shows everything a principal can do grouped by ability, iam analyze runs static checks over every policy, and iam templates creates ready-made policies. A newly attached Deny takes effect on the very next request.
test/e2e/iam_policy_enforcement_test.go covers this end to end: it drives a real admin session through the real HTTP API to mint a user and a token, attach Deny and Allow policies, and confirm the resulting 403 and 200 responses.
Bootstrap: exactly one path to the first admin
On startup,BootstrapAdmin creates a user from APP_ADMIN_USERNAME and APP_ADMIN_PASSWORD only if zero users exist. It is a no-op on later restarts, so a password change doesn’t get silently reverted.
Without those env vars and with no user yet, the control plane generates a one-time setup token, writes it to <data dir>/setup-token (mode 0600), and logs it once.
GET /api/v1/auth/setup-status(public) returns{"needs_setup": true}until the first admin exists, and the login page switches to its setup form on its own.POST /api/v1/auth/registerrequires the token insetup_token(constant-time compare) and deletes the file on success, so whoever reaches the dashboard first can’t claim the instance without shell access to the server.install.shprints the token and ahttp://<ip>:8080/login?setup=<token>link;sudo levelrail setup-tokenprints it again.
POST /api/v1/auth/register is gated at the database layer to succeed exactly once (a unique constraint, not an application check), so concurrent first-registration attempts cannot create two “first” admins. Either path grants AbilityRoot because no one else exists yet.
Subsequent users. Every user after the first is created by an existing root user, either directly (POST /api/v1/auth/users) or via an accepted invite.
Two-factor authentication (TOTP)
Requires a master key configured on the control plane (internal/secrets). Without one, every 2FA route returns 501.
POST /api/v1/auth/2fa/setup: mint a fresh TOTP secret and store it unconfirmed. Calling it again before confirming overwrites the pending secret.POST /api/v1/auth/2fa/confirm: validate one live code, flipTOTPEnabled, and return 10 recovery codes in plaintext. This is the one and only time they are shown.- On login,
POST /api/v1/auth/loginreturnsmfa_required: trueplus a short-livedmfa_token(5 minutes) instead of a session. Exchange it with a live code (or a recovery code) viaPOST /api/v1/auth/2fa/verifyfor the session cookie.
POST /api/v1/auth/2fa/disable re-verifies the second factor itself, not the account password, because a password-only re-check would undermine 2FA. Regenerating recovery codes invalidates the entire previous set.
Single-use codes. A TOTP code is accepted once. The control plane remembers the newest time step it accepted for each user (users.totp_last_step, migration 0350), so a code that was just used or replayed inside its roughly 90 second validity window is refused with invalid code. The check is atomic, so two simultaneous logins presenting the same code cannot both pass. The step is claimed at login (/2fa/verify), not at /2fa/confirm, so the code you enrol with can still be used once to sign in. Recovery codes are single-use too.
Rate limiting. Both the login-time verify step and every setup/confirm/disable call are rate limited (exponential backoff with a handful of free failures), separately from the password rate limiter.
Passkeys (WebAuthn)
Sign in with Touch ID, Windows Hello, or a security key instead of a password. No master key required: a credential’s public key is ordinary key material, not a secret, so it never goes throughinternal/secrets.
Host header, since there is no single fixed domain to configure on a self-hosted platform.
OAuth sign-in
Four providers are supported:google, github, microsoft (Azure AD, common multi-tenant endpoint), and oidc (generic OpenID Connect, requires an issuer URL).
Settings are per-provider rows (GET/PUT /api/v1/settings/oauth[/{provider}]), gated at AbilityRoot to change. Enabling a provider requires a client ID and a client secret (OIDC also requires an issuer URL). The secret is write-only over the API; GET only reveals has_client_secret.
Team invites
POST /api/v1/invites mints a token for one named email, with either --role or --abilities.
- Privilege check: The caller cannot invite someone with an ability they don’t hold themselves.
AbilityWritegates the route, but that per-ability cap is the actual boundary. - Accept link: The response always includes the plaintext accept link, whether or not SMTP is configured, so a control plane with no email is still fully usable by copy and paste.
- Acceptance: Default TTL is 7 days (
APP_INVITE_TTL).POST /api/v1/invites/acceptcreates a normal local-password user through the same path asPOST /api/v1/auth/users, so an invited account is indistinguishable from an admin-created one. - Visibility: A non-root caller only sees invites they created. A root caller sees every pending invite.
GET /api/v1/users follows the same visibility idea: a root caller lists every account, any other caller (a viewer, an operator, a scoped token) gets only their own record, or an empty list for a token. Emails and ability sets of colleagues are not visible to read-tier callers.
API tokens
Scoped, revocable bearer credentials for the CLI, CI, and MCP integrations. Minted with the same ability vocabulary users carry, plus an optional expiry (expires_in_days, 0 means never). The plaintext is returned exactly once at creation; every later read (GET /api/v1/auth/tokens) shows only metadata. The security policy can cap the lifetime of new tokens and disable tokens that go unused, after a notice: see API token hygiene.
Session-only management. Creating and revoking tokens is deliberately session-only, never bearer-token authenticated: a token can never mint or revoke another token. This is why the CLI’s tokens create/revoke and auth login prompt for username and password instead of accepting --token. Listing metadata (GET /api/v1/auth/tokens) also accepts a bearer token that holds root.
Device-code flow. POST /api/v1/auth/device/start and /token (levelrail-cli auth login --device) sidestep the plain-HTTP session-cookie gap.
- The CLI polls with a random device code.
- An operator approves it from the dashboard’s CLI Access page (
/settings/cli-access) using their existing session. - The resulting token inherits exactly that operator’s abilities. The CLI never picks the permissions.
Session links
A session link is a one-time login URL, not a credential you type. Mint one from an authenticated context and hand the URL to whatever needs to sign in next: CI, an AI agent driving the dashboard through browser automation, or a fresh incognito window. Opening it signs that browser in immediately.POST /api/v1/auth/session-links mints one. It is gated root, because the link is root-equivalent: anyone who gets hold of the token before it’s used can sign in as whoever minted it.
- CLI
- API
- Short-lived: The token expires 2 minutes after minting (default,
APP_AUTH_ENGINE_SESSION_LINK_TTL), whether or not it’s used. - Single-use: Consuming it is an atomic claim; if two requests race on the same token, at most one succeeds and the other gets the same “invalid or expired” error a stale token would.
- Can’t escalate: The session carries exactly the minting identity’s own abilities, snapshotted at mint time. Minting already requires
root. - A link minted on behalf of an API token (rather than a logged-in user) produces a session pinned to that token’s ability snapshot. Revoking the token afterward does not end a session already established from it, the same way revoking a token never ends sessions a user already has open.
Sign in with a code
The sign-in page offers Sign in with a code instead. You type your username or email and get a one-time code you type back in, no password.Request
POST /api/v1/auth/login-code/request always answers 202 with the same message and takes at least APP_LOGIN_CODE_MIN_RESPONSE (default 300ms), whether the account exists, may use codes, or not. An account that cannot get a code still gets a decoy challenge, so redeeming behaves the same way too.Bind to the browser
SameSite=Strict cookie bound to that browser. The code only works together with that cookie, so a code read over someone’s shoulder is useless in another browser.Read the code
levelrail-cli auth code to print it from a terminal.Redeem
POST /api/v1/auth/login-code/redeem with the code signs the browser in. If the account has two-factor authentication on, the code replaces only the password: the response asks for the TOTP or a recovery code next, exactly like a password sign-in.- 8 characters from a 32 symbol alphabet with no lookalikes (Crockford base32), from
crypto/rand. Dashes, spaces, lower case andO,I,Ltyped for0,1,1are accepted. - Single use, stored only as an HMAC keyed with a per-challenge salt and, when the control plane has its encryption key, a server secret, so a copy of the database alone cannot test guesses. Compared in constant time. The plaintext lives in control plane memory until used or expired; after a restart a waiting code can no longer be shown, so ask for a new one.
- The requester’s browser is shown as a short label such as “Firefox on Linux”, built from a fixed list of names. The raw User-Agent text never reaches an email or the dashboard.
- The request, redeem and poll routes accept only
Content-Type: application/jsonand refuse a cross-siteOriginorSec-Fetch-Site. - Codes never appear in logs, the audit log, the attention feed or any list endpoint. The only route that returns one is
POST /api/v1/auth/sign-in-requests/codes/{id}/reveal, for the account’s own session or a token owned by that user that holdssignin:approve, and each reveal is audited.
Rate-limit and live-code details
Rate-limit and live-code details
- A request spends from the rate budgets only when all of them have room, so requests refused by one limit never drain another.
- A browser already signed in to the account, or trusted for it, draws from its own account budget, so a stranger typing your name cannot use it up.
- Past
APP_LOGIN_CODE_MAX_LIVE, a new stranger request expires the oldest, so nobody can hold the slots and keep the owner out. Only the first live code is emailed. - A request from a browser already signed in to, or trusted for, the account skips the live-code cap and is always emailed, within the hourly email budget.
- The attention center shows one item per account with a count, not one per request.
signin:approve ability. A code is worth a session, so showing one, and deciding a new-browser approval, needs either a signed-in session of that account or a token that holds signin:approve. That ability is never implied: root, write:sensitive and every device login (levelrail-cli auth login --device) leave it out, so a leaked CLI token cannot turn itself into a dashboard session. Users and roles can never hold it.
Mint such a token only from a signed-in session: Settings > API tokens > Create token > Approve my sign-ins, or:
APP_SIGNIN_APPROVE_TOKEN_MAX_DAYS (default 30) days and cannot be an agent token. It is revoked when the account’s password is changed or reset, when it signs out its other sessions, and by recover-admin (audited as new_device.approver_tokens_revoked). Listing waiting requests and managing trusted browsers still accept write:sensitive.
Who may use codes. The auth.code_login setting decides: Settings > Security, levelrail-cli auth code-login, or PUT /api/v1/settings/auth/code-login (root). It is off for admin (root) accounts and on for everyone else until an admin saves it; APP_AUTH_CODE_LOGIN_ADMINS and APP_AUTH_CODE_LOGIN_OTHERS change those defaults.
New-browser approval
When a password sign-in is correct but comes from a browser without a trusted-device cookie, and the account already has a live session somewhere else, the sign-in pauses. The new browser shows a waiting screen; every other session of that account sees a banner and an attention item with the requester’s IP address, browser and time, and can Approve or Deny. A token owned by the user that holdssignin:approve can decide too: levelrail-cli auth code lists the request, then levelrail-cli auth approve <id> --match <number> or auth deny <id> decides it.
Number matching. The waiting browser shows a two digit number. Approving means typing that number (in the dashboard, or --match NN in the CLI); a wrong number denies the sign-in, so approving a prompt you did not expect does not let anyone in. The number never appears in a list, the attention feed or the CLI output, only in the waiting browser.
- Only one approval waits per account: a new paused sign-in replaces the previous one (audited as
new_device.superseded). - Once approved, the waiting browser gets its session exactly once (
POST /api/v1/auth/login-approval/poll, bound to that browser’s cookie). If the paused sign-in’s session cannot be revoked, the login fails with an error and no approval is created. - Accounts with two-factor authentication on are not paused: password plus TOTP still signs in without a separate approval. Passing TOTP does not trust the browser on its own; tick Remember this browser for N days on the two-factor step (off by default) to trust it.
- An account with no other live session signs in as before, so the last way in is never blocked.
- Setting
auth.approval_scopetoall_methods(Security center > Policy,levelrail-cli security policy set --approval-scope all_methods, orAPP_AUTH_APPROVAL_SCOPE) pauses passkey and OAuth/OIDC sign-ins from a new browser too. A held OAuth sign-in returns to the sign-in page to wait. Each account can also require approval for itself with Require approval for new browsers on my account. See Security center. - A sign-in from a browser and network the account has not used before emails the owner a single-use this wasn’t me link that signs that session out, drops trusted browsers and flags the account for a new password. See New sign-in alerts.
levelrail-cli auth devices revoke <id>.
Every trusted browser of an account is revoked when its password is changed or reset, when it signs out its other sessions, when recover-admin resets it, and when the user is deleted. The same events, plus turning two-factor authentication off or removing a passkey, also cancel the account’s waiting approvals (approved ones not yet collected included) and lock its live sign-in codes. Expired entries are removed by the periodic sweep.
Sign-in audit event names
Sign-in audit event names
login_code.requested, login_code.delivered (email at send time, dashboard or CLI at each reveal), login_code.redeemed, login_code.failed, login_code.locked, login_code.expired, new_device.requested, new_device.approved, new_device.denied, new_device.expired, new_device.superseded, new_device.trust_revoked and new_device.approver_tokens_revoked.The security center adds its own security.* events, listed in Security center. Requests from someone who is not signed in are recorded with the actor Sign-in and the target user id when the account exists.Audit log
Every request gated aboveAbilityRead (write, deploy, root-tier, and read:sensitive) gets one row:
GET /api/v1/audit-log is cursor-paginated (?before, an RFC3339 timestamp) and filterable by ?path, ?method, ?client_kind, and ?agent (the agent label of the token, see AI assistant). Use ?format=csv to return rows as a downloadable attachment for compliance export. Cells that start with =, +, -, @ or a tab get a leading ' in the CSV, so a token named =HYPERLINK(...) is not evaluated as a formula when the export is opened in a spreadsheet.
Retention. Defaults to 90 days (APP_AUDIT_LOG_RETENTION_DAYS). The system sweeps automatically on an interval (APP_AUDIT_LOG_SWEEP_INTERVAL) and is purgeable on demand via POST /api/v1/audit-log/purge.
API reference
Sessions and account
Sessions and account
Two-factor authentication
Two-factor authentication
Passkeys
Passkeys
OAuth sign-in
OAuth sign-in
Device login (CLI)
Device login (CLI)
Sign-in codes, approvals and trusted browsers
Sign-in codes, approvals and trusted browsers
Users and roles
Users and roles
API tokens
API tokens
Session links
Session links
Invites
Invites
IAM policies
IAM policies
Audit log
Audit log
CLI
Known limits
Known limits
- There is no per-team or per-project access boundary. IAM policies scope to individual resources (
app:name,database:name) or a wildcard. The Organizations settings page groups projects for display and navigation only. - There is no SSO/SAML and no SCIM provisioning. OAuth covers Google, GitHub, Microsoft and generic OIDC, and nothing else today.
- Sessions are in memory. A control plane restart signs everyone out, and waiting sign-in codes can no longer be shown.
Next steps
Access control
Environments
Security overview
AI assistant integration
CLI reference
Managing databases
write:sensitive.