Manual · docs/authentication.md

Authentication and organization access

Code: crates/pushleft-api/src/auth.rs and auth_hardening.rs (rate limits, TOTP), migrations 0027_production_auth.sql and 0038_control_plane_ha_and_auth_hardening.sql, tests src/auth_tests.rs, src/auth_hardening_tests.rs, the production phase of scripts/e2e.sh and scripts/auth-hardening-e2e.sh (npm run test:auth-hardening). CLI: pl login, pl logout, pl whoami, pl console-code, pl totp. Console: sign-in screen and sign-out in src/main.jsx.

Modes

PUSHLEFT_AUTH_MODE selects one of two modes. The API refuses to start on any other value.

development (default)production
CredentialPUSHLEFT_DEV_TOKEN (≥ 32 characters), fixed PUSHLEFT_DEV_ORG_IDAPI tokens and console sessions in PostgreSQL
Bind addressloopback only (unchanged refusal)any
Transportplain HTTP on loopbackmust arrive from PUSHLEFT_TRUSTED_PROXIES with X-Forwarded-Proto: https
Refuses to start whenthe token is missing/short or the bind address is not loopbackPUSHLEFT_DEV_TOKEN/PUSHLEFT_DEV_ORG_ID are set, PUSHLEFT_TRUST_PROXY_TLS is not 1, the proxy list is empty, invalid or /0, or PUSHLEFT_TLS_CERT/PUSHLEFT_TLS_KEY is set

Development mode is the old behaviour unchanged: every request acts as the owner of the dev organization. Identity endpoints return 409 there.

Production mode accepts TLS evidence only from a terminating proxy. The API checks every authenticated request and the login request: the TCP peer must be in a trusted CIDR and the proxy must have set X-Forwarded-Proto: https. If not, the API returns 403 https_required. The proxy must overwrite, not append to, any X-Forwarded-Proto header from the client. The API records the right-most X-Forwarded-For entry as the session IP. There is no direct TLS listener (see gaps below).

Credentials

API tokens look like pl_<prefix8>_<base64url of 32 random bytes>. The API stores only SHA-256(secret) and the prefix, which is a non-secret display hint. The API also checks the prefix after the lookup. A slow password hash such as Argon2 protects low-entropy human passwords from offline guessing. A 256-bit random secret cannot be guessed, so a fast hash gives the same protection. A fast hash also allows an indexed equality lookup on the hash, so no secret comparison happens in application code and there is no timing side channel. Tokens expire after 90 days by default (1–366 days allowed). A token belongs to exactly one organization.

  • A personal token has a user_id. Its role is a ceiling: the effective role is the lower of the ceiling and the user’s current membership role. Demoting or removing a member changes or ends their tokens at once.
  • A service token has no user. Its role is its role. Only admins can create one.
  • Scope ro caps the effective role at viewer.

Console sessions use the cookie __Host-pl_session (HttpOnly, Secure, SameSite=Lax, Path=/, 12 hours). The session value is 32 random bytes, and the database stores only its SHA-256. A session belongs to a user, not to an organization. A request selects an organization with X-Pushleft-Org: <uuid>. Without that header, the API uses the user’s oldest membership. The API checks membership on every request.

Login codes (plc_…) are single-use and expire after 60 minutes. They exist because the platform cannot send email yet. You can issue a code to yourself with pl console-code, or an admin can issue one to a member with POST /v1/auth/login-codes {"email": …}. You redeem a code with POST /v1/auth/session {email, code}. The console receives a cookie. pl login --email --code receives a new personal token. Only owners can issue codes for owners.

The console has no password flow. Login codes plus CLI tokens cover sign-in without new crates or password-reset machinery. Add passwords (Argon2id) or SSO when there is a delivery channel for reset.

Request identity

Every route extracts one AuthContext { org_id, user_id, role, scope, credential }. In production, the extractor runs pushleft_authenticate_token or pushleft_authenticate_session in a short transaction of its own. These functions are SECURITY DEFINER because they run before the tenant is known. They take only a credential hash and return only that credential’s row. The extractor rejects a credential that is unknown, revoked, expired, belongs to a disabled user, or has no membership. It writes last_used_at at most once a minute. Each handler then calls scope_transaction to set app.org_id from the context, as before, so the existing RLS policies apply unchanged.

The API looks up the credential on every request, so revocation, expiry, disabling and membership removal take effect on the next request. The SSE stream checks the credential again before each poll (about every 750 ms). When the check fails, it sends event: unauthorized and closes. The stream cursor stays scoped to the context’s organization.

Roles

viewer < member < admin < owner. Each handler calls principal.require(Role::…) first.

Route familyviewermemberadminowner
Deployments: list, get, SSE stream✓✓✓✓
Deployments: create, teardown, source upload✓✓✓
DNS zones/records, domain orders, database instances/catalogs/principals: read, preset preview✓✓✓✓
Custom domains: list, show✓✓✓✓
Custom domains: add, remove✓✓✓
DNS record sets: create/update/delete; database catalogs/principals: create; domain quotes✓✓✓
DNS zone creation; domain checkout and price acceptance (spends money)✓✓
Personal tokens (own role or lower); list/revoke own tokens✓✓✓
Service tokens; list/revoke any token in the org; add/change/remove non-owner members; login codes for members✓✓
Grant, change or remove ownership✓

An organization always keeps at least one owner. An advisory lock serializes concurrent demotions. Viewers can revoke their own tokens. Viewers cannot create tokens; they get a CLI token through pl login --email --code.

Endpoints

Method and pathPurpose
GET /v1/auth/meContext, user, and the user’s memberships across organizations
POST /v1/auth/sessionPublic. {email, code, client?} → cookie, or {token} when client is "cli". 202 {totp_required, challenge, expires_at} when the user has TOTP. Requires X-Pushleft-CSRF: 1. Rate limited
POST /v1/auth/session/totpPublic. {challenge, code} or {challenge, recovery_code} → the cookie or token the login asked for. Requires X-Pushleft-CSRF: 1. Rate limited
GET /v1/auth/totpYour TOTP state: none, pending or active, recovery codes left
POST /v1/auth/totp/enrollA new pending secret: {credential_id, secret, otpauth_uri}, shown once
POST /v1/auth/totp/confirm{code}: activates; returns ten recovery codes, shown once
POST /v1/auth/totp/disable{code} or {recovery_code}
GET/PUT /v1/organizations/{id}/security{require_totp_for_admins}. Read: any member. Change: owners only
DELETE /v1/auth/sessionSign out: revoke the current session and clear the cookie
POST /v1/auth/login-codes{email?}: a one-time code for yourself, or (admin) for a member. Rate limited per address and target user
POST /v1/auth/tokens{name, scope?, role?, expires_in_days?, service?}. The response shows the token once
GET /v1/auth/tokensAdmins see all tokens in the org; others see their own
DELETE /v1/auth/tokens/{id}Revoke. The row stays as history
GET/POST /v1/orgs/{id}/membersList, or add/change {email, role}
DELETE /v1/orgs/{id}/members/{user_id}Remove a member

{id} must be the credential’s organization. Any other id returns 404, so a token cannot probe other organizations.

Rate limiting

Production mode only. Counters live in PostgreSQL (auth_rate_limits, fixed windows keyed by scope:kind:subject), so every API replica shares them. pushleft_rate_limit_hit (SECURITY DEFINER) counts one attempt atomically and prunes windows older than two days on about 1 % of writes; the counters are not history and are the only rows Pushleft deletes. Client address is the TLS proxy’s right-most X-Forwarded-For entry, trusted only from PUSHLEFT_TRUSTED_PROXIES with PUSHLEFT_TRUST_PROXY_TLS=1 (the same rule as the session IP); otherwise the TCP peer.

WhatBucketsCountedDefault per window
Login-code exchange POST /v1/auth/sessionlogin:ip:<address>, login:subject:<email>every attempt, before the code is looked at, so guesses beyond the limit are never evaluated30 per address, 10 per email
TOTP challenge POST /v1/auth/session/totptotp_challenge:ip:<address>, then totp:user:<user>every attempt30 per address, 10 per user
TOTP confirm / disabletotp:user:<user>every attempt10 per user
Login-code issuance POST /v1/auth/login-codeslogin_code_issue:ip, login_code_issue:subject:<target user>every attempt30 / 10
Invalid token or session on any routecredential:ip:<address>failures only (401 unauthorized; a missing credential is not counted)100 per address

PUSHLEFT_AUTH_RATE_WINDOW_SECONDS (300, 1–86400), PUSHLEFT_AUTH_RATE_IP_LIMIT (30), PUSHLEFT_AUTH_RATE_EMAIL_LIMIT (10) and PUSHLEFT_AUTH_RATE_TOKEN_IP_LIMIT (100, all 1–100000) set them; the API refuses to start on other values. Over a limit the answer is 429 rate_limited with Retry-After (seconds until the window ends). The first refused attempt per bucket and window writes one auth_events row rate_limited with no organization and {scope, bucket, subject, limit, window_seconds}: the address or email, never a code or token. A flood therefore adds at most one row per bucket per window. For invalid tokens, a valid token from a limited address still works, so an attacker cannot lock real users out by spraying garbage from a shared NAT; it only turns their own 401s into 429s.

Lockout ends when the window ends. An operator can lift it early (owner role): DELETE FROM auth_rate_limits WHERE bucket = 'login:subject:alice@example.com'. bootstrap-owner is not limited: it runs on the host with the owner’s DATABASE_URL and is not reachable over the network. Alert: auth_rate_limited_spike (operations.md).

Two-factor authentication (TOTP)

RFC 6238 TOTP: HMAC-SHA1, 6 digits, 30 s steps, a 160-bit secret, ±1 step of clock tolerance. Each credential stores the last accepted step, and a code for that step or an earlier one is refused (totp_replayed), so an observed code cannot be reused. Secrets are sealed with AES-256-GCM (ring) under the key in PUSHLEFT_AUTH_TOTP_KEY_FILE (base64 of 32 bytes; the API refuses a file with group or other permission bits). The additional data binds each ciphertext to its credential and user row. The secret leaves the API only in the enroll response; it is never logged or put in auth_events.

State machine (totp_credentials.status, rows are never deleted; a trigger refuses DELETE): pending → active (first valid code) → disabled (a valid code or recovery code). Enrolling again while pending marks the old secret superseded; while active it is refused (disable first). Activation returns ten recovery codes (XXXX-XXXX-XXXX-XXXX, 80 random bits, stored as SHA-256, case and dashes ignored). Each works once (recovery_code_used records how many are left); they belong to that credential and die with it.

Login. When the user has active TOTP, POST /v1/auth/session still consumes the login code but returns 202 {totp_required: true, challenge: "plt_…", expires_at} instead of a credential. The challenge (stored as a hash in totp_challenges) is bound to the user, organization and client (console or cli), lives 5 minutes, and allows 5 wrong codes. POST /v1/auth/session/totp with a valid code or recovery code issues exactly what the login asked for, once. Console sessions created this way carry totp_verified. API tokens (pl_…) are not affected: once issued they work without a second factor, including tokens issued before TOTP was enabled.

Organization requirement. PUT /v1/organizations/{id}/security {"require_totp_for_admins": true} (owners only; the owner’s own TOTP must already be active, so the setting cannot lock out its author). Then:

  • an owner or admin without active TOTP who exchanges a login code for that organization gets 403 totp_enrollment_required (audited as failed_auth);
  • a console session of an owner or admin that did not pass a TOTP challenge is refused with credential rejected: totp_required for that organization, whichever organization the session was created for;
  • owners and admins cannot disable their TOTP there (409);
  • members and viewers are unaffected; existing API tokens keep working.

An admin who has no TOTP yet enrolls with an existing personal token (pl totp enroll), or an owner demotes them, they sign in and enroll, and are promoted back.

CLI. pl totp enroll prints the otpauth:// URI (and the secret on stderr), pl totp confirm --code <code> prints the ten recovery codes, pl totp status, pl totp disable --code <code> or pl totp disable --recovery (the recovery code is read from standard input, never from arguments). pl login --email --code prompts for the TOTP code (or a recovery code) when the API asks for one; --totp <code> passes the short-lived authenticator code non-interactively and --recovery reads a recovery code from standard input.

Console. Minimal: when the login answers with a challenge, the sign-in screen asks for an authenticator code or a recovery code and completes it (src/main.jsx, completeTotp in src/api.js). It is built but not covered by a browser test. Enrollment, disabling and the organization setting have no console screens (gap); use pl totp and the API.

Without PUSHLEFT_AUTH_TOTP_KEY_FILE, the TOTP endpoints return 409, and a user who already has active TOTP cannot log in (503, fail closed). Key rotation is not implemented (gap): replacing the key makes every enrolled secret unreadable, so those users must have their TOTP reset by an operator (UPDATE totp_credentials SET status = 'disabled', ended_at = now() WHERE user_id = …, then re-enroll).

CSRF model

Bearer requests cannot be forged cross-site. For cookie-authenticated requests, every method except GET, HEAD and OPTIONS requires the custom header X-Pushleft-CSRF: 1. The login request requires it too, which also blocks login CSRF. A cross-site form or simple request cannot set custom headers, and the API has no CORS policy. SameSite=Lax adds a second layer. If CORS is ever enabled for foreign origins, this defence must be replaced with a per-session double-submit token.

Onboarding and revocation

  1. Start in production mode behind the TLS proxy.
  2. Run pushleft-api bootstrap-owner --email you@example.com [--org-name "Acme"], with the migration-owner DATABASE_URL, because it bypasses tenant RLS. The command creates the organization, the owner and one 90-day owner token, and prints the token once on stdout. If you run it again with the same email, it issues a new token and creates no new organization (operator recovery). It refuses any other email once an owner exists.
  3. pl login (paste the token), pl whoami, pl up. Credentials are stored as JSON {api_url: token} in $XDG_CONFIG_HOME/pushleft/credentials (directory 0700, file 0600). PUSHLEFT_TOKEN or --token still overrides the stored token. pl logout forgets the stored token only. Revoke it with DELETE /v1/auth/tokens/{id} to invalidate it.
  4. Add teammates with POST /v1/orgs/{id}/members, then hand them a login code out of band.

Production database roles need EXECUTE on the five pushleft_* auth functions. The migration revokes PUBLIC execute from them.

Audit

The auth_events table is append-only (a trigger rejects UPDATE and DELETE). It records bootstrap, login (with second_factor: null, totp or recovery_code), logout, token_created, token_revoked, membership_changed, login_code_created, totp_enrolled, totp_activated, totp_disabled, recovery_code_used, security_settings_changed, rate_limited (no organization), and failed_auth with a reason (revoked, expired, user_disabled, not_a_member, login_code_reused, login_code_expired, totp_invalid, totp_replayed, totp_not_active, recovery_code_invalid, recovery_code_reused, challenge_used, challenge_expired, challenge_exhausted, totp_required, totp_enrollment_required). It stores ids only, never secrets. pushleft-api audit --org <uuid> lists an organization’s rows, --org none the organization-less ones (rate_limited). RLS lets only owners and admins of the organization read it. Unknown tokens and unknown login codes go to the log and not to the table, so unauthenticated guessing cannot grow it.

Threat model

In scope:

  • Stolen database rows: they contain hashes only.
  • Cross-organization access: the context comes from the credential, and the API, RLS and 404 responses on foreign ids all block it.
  • Privilege escalation: tokens cannot exceed their creator’s role, admins cannot touch owners, and ro tokens are capped at viewer.
  • CSRF on the console.
  • Bypassing the proxy: TLS evidence is required on every request.
  • Lingering access after revocation, expiry, disabling or removal: the API checks credentials on every request, and the SSE stream checks them during streaming.

Out of scope or missing:

  • SSO / OIDC / SAML: not implemented.
  • 2FA: TOTP and recovery codes are implemented (above). WebAuthn/passkeys are not. There is no console UI for TOTP enrollment, no TOTP key rotation, and no self-service reset when a user loses both the authenticator and the recovery codes (operator SQL).
  • Password login and reset: not implemented. Login codes are handed over out of band because there is no email delivery yet. Whoever relays a code can sign in as that user.
  • Token rotation policy: there is only a 90-day default expiry (366 days at most). There is no forced rotation, no expiry warnings and no refresh tokens. Service tokens are bearer secrets with no IP or audience binding.
  • Rate limiting and lockout: implemented per address and per email/user (above). Every counted attempt is one PostgreSQL upsert, so a large distributed flood still reaches the database; keep a coarse limit at the proxy as well. There is no permanent lockout or CAPTCHA, only fixed windows.
  • Session management UI: you can only sign out the current session. Listing other sessions and revoking all of them is missing. Disabling a user (users.disabled_at, set by an operator) ends every credential.
  • Org creation, org settings and org deletion endpoints: missing. Only bootstrap creates an organization.
  • Direct TLS listener: missing. Production requires a trusted terminating proxy.
  • Viewers and source upload: a viewer’s source upload is refused only after the body has been read, so a viewer can briefly hold an upload slot.
  • Agent, scheduler and controller tenant isolation: not covered by this work. Those services use mTLS node identity (see agent-gateway.md).
Rendered from the repository at build time. Edit docs/authentication.md, not this page.