Authentication (Phase 3)¶
sqi ships with authentication off by default — on a trusted local network, every request is served as an anonymous superuser and nothing is gated. This is the pre-Phase-3 behavior and remains the default.
The opt-in gate¶
The single switch is auth.enabled (config file auth.enabled, env
SQI_AUTH_ENABLED, flag --auth-enabled; default false).
As of component A1, the gate is live: flipping auth.enabled to true and
restarting actually locks the server down. Every REST request and the
WebSocket upgrade now require a valid credential — a session cookie or a
Bearer API key (auth.Chain(apikey, session)); there is no more
"scaffolding only" caveat. See Local accounts,
Login & sessions and API keys for what that
means in practice, and First-admin bootstrap for
how to get your first credential.
Model¶
Every request carries a Principal in its context. When auth is off, the
middleware injects an anonymous principal with the superuser flag set, so
authorization checks are bypassed. Authentication is pluggable: an
Authenticator resolves a request's credentials to a Principal. Today the
only non-anonymous Authenticators are the session-cookie and API-key ones
described below.
Externally-verified credentials do not implement that interface.
LDAP/AD (C1) attaches at POST /auth/login, and
OIDC/SSO (C2) attaches at its own callback route; both verify the
credential once and then mint an ordinary session, so no request path binds
against an external identity provider. See It attaches at login, not at every
request.
Principal carries Subject (opaque user id), Username (login name — the
value bound to Job.Owner/Job.Submitter), DisplayName, Roles, Kind,
and Superuser.
GET /auth/me returns both roles and permissions. Clients should gate on
permissions: it is computed server-side from the policy matrix, so an
externally-mapped role from an LDAP or OIDC provider (C1/C2) needs no
client-side change. A superuser principal — the anonymous identity used when
auth is disabled — reports the full permission set, which is what keeps every
control enabled in an auth-off deployment.
A Principal's roles field is populated (a user's single stored role, e.g.
["admin"]) and, as of component B1, is enforced: every mutating route
and several read routes are gated by a role→permission policy. See
Roles & permissions for the matrix.
The REST resource routes are gated by the auth middleware; the WebSocket upgrade is gated by its own hook; the health/readiness/metrics probes and the OpenAPI spec are always public.
auth.enabled does not gate /debug/pprof/. The Go runtime profiling
endpoints are mounted on the root router, outside /api/v1 entirely
(internal/api/router.go), so the auth middleware, the CSRF guard and the
rate limiter all miss them whether auth is on or off. They are opt-in
(http.enable_pprof, default false) and the server logs a WARN at boot
when they are enabled. Treat that warning as literal. A pprof profile does not
dump memory contents — it will not hand out session tokens, the LDAP bind
password or the OIDC client secret — but it does expose internal code paths,
allocation sites, live goroutine stacks, build and module metadata, and the
process command line to anyone who can reach the port. Worse, because these
routes sit outside the rate limiter, /debug/pprof/profile?seconds=N and
/debug/pprof/trace?seconds=N accept an arbitrary duration from an
unauthenticated caller, which is a straightforward availability lever. Enable
pprof only on a network you would already trust with an auth-off deployment,
or put a reverse proxy in front of /debug/.
Local accounts¶
A local account (internal/store User) has a username (case-insensitive
unique), a display name, a role (admin, operator, user, or
read-only; defaults to user if omitted), and a disabled flag. Passwords
are hashed with argon2id (OWASP-baseline parameters: 19 MiB memory, 2
iterations, 1 thread — internal/auth/password/password.go) and never
returned by any endpoint.
Accounts are created two ways:
- Bootstrap — exactly one admin account, seeded at startup. See First-admin bootstrap.
- The
/usersREST API (and the web Admin → Users page) —POST /api/v1/users,GET /api/v1/users,GET /api/v1/users/{id},PATCH /api/v1/users/{id}(display name / role / disabled),PUT /api/v1/users/{id}/password,DELETE /api/v1/users/{id}.
Disabling a user (disabled: true) takes effect immediately: the session
authenticator re-checks the user record on every request and rejects a
disabled account's session outright (internal/auth/session/session.go), and
POST /auth/login refuses a disabled account with the same generic 401 as a
bad password. Deleting a user cascades to its sessions — see
Login & sessions.
Roles & permissions¶
As of component B1, every mutating route and several read routes are gated by a role→permission policy. There are four built-in roles (no custom-role builder — YAGNI):
- admin — full access, including user management, API-key management for any account, and configuration-adjacent surfaces.
- operator — runs the farm: all jobs, workers, farm infrastructure (farms/queues/storage/compute/usage-pools), products/presets, and diagnostics (server log).
- user — submit and control their own jobs; manage their own API
keys; read-only on infrastructure. This is the only role without
jobs.read.all, so it neither sees nor may mutate a job owned by someone else — note the counter-intuitive consequence thatread-onlysees every job whileusersees only its own. - read-only — reads the operational surface; no mutations anywhere; cannot see diagnostics or the user list — but can manage its own API keys.
| Permission | read-only | user | operator | admin |
|---|---|---|---|---|
| jobs.read | ✅ | ✅ | ✅ | ✅ |
jobs.read.all — see jobs owned by anyone |
✅ | ❌ | ✅ | ✅ |
| jobs.write | ❌ | ✅ | ✅ | ✅ |
jobs.submit_as — set Owner to another user |
❌ | ❌ | ✅ | ✅ |
| workers.read | ✅ | ✅ | ✅ | ✅ |
| workers.manage | ❌ | ❌ | ✅ | ✅ |
| infra.read (farms/queues/storage/compute/usage-pools) | ✅ | ✅ | ✅ | ✅ |
| infra.manage | ❌ | ❌ | ✅ | ✅ |
| products.read (products/presets) | ✅ | ✅ | ✅ | ✅ |
| products.manage | ❌ | ❌ | ✅ | ✅ |
| diagnostics.read (server log) | ❌ | ❌ | ✅ | ✅ |
| users.read | ❌ | ❌ | ❌ | ✅ |
| users.manage | ❌ | ❌ | ❌ | ✅ |
| apikeys.self (own keys) | ✅ | ✅ | ✅ | ✅ |
| apikeys.admin (anyone's keys) | ❌ | ❌ | ❌ | ✅ |
isolation.manage — set a queue's run_as_user/run_as_group |
❌ | ❌ | ❌ | ✅ |
apikeys.admin is enforced by GET /users/{id}/api-keys and
DELETE /users/{id}/api-keys/{keyId} — see API keys.
A denied request returns 403 with an RFC-7807 problem-details body, and
is recorded to the audit log (AuditEntry.Actor) as well as the server's own
diagnostic log. With auth.enabled=false (the default), the anonymous
superuser principal bypasses every check — unchanged behavior from before
B1.
Last-admin guard. The last enabled admin account can't be deleted, disabled, or demoted to a non-admin role — any of those requests fail with 409 Conflict — so an operator can never lock themselves (or everyone) out of user management.
Self-service account changes¶
Any authenticated principal may change its own display name and password.
Both routes resolve their target from the session — there is no id in the
path — so reaching another account is structurally impossible rather than
guarded against. The web surfaces them at /account, linked from the
sidebar identity control.
| Route | Effect |
|---|---|
PATCH /api/v1/auth/me |
Sets display_name. Returns the same principal shape as GET /auth/me. A body omitting display_name is a successful no-op returning the caller's identity unchanged, not an error. |
PUT /api/v1/auth/password |
Verifies the current password, then sets the new one. |
Three choices worth knowing:
- Only
display_nameis accepted.role,disabled, andusernameare absent from the request type, so a body carrying them is inert — a self-service route that could reachrolewould be a privilege-escalation hole, and this makes that unrepresentable rather than merely checked for. - A wrong current password is 403, not 401. The caller is authenticated and only failed a re-auth check; a 401 would trip the web's login interceptor and eject them mid-form.
- Changing a password evicts every session for the account, then re-issues one for the caller — other devices are signed out while the device that made the change stays signed in. API keys are deliberately not revoked: they are an independent credential, and silently killing a user's automation because they rotated a password would be a nasty surprise. Revoke them explicitly if that is what you want.
With auth.enabled=false both routes return 409 Conflict — the
anonymous superuser has no account record to change — and the web hides the
Account link entirely.
Job identity¶
POST /api/v1/jobs and POST /api/v1/products/{name}/jobs resolve the
Owner and Submitter persisted on a job from the authenticated principal
and whatever the client supplied, following this precedence:
- Submitter is always the principal's username. A client value is discarded silently, never an error — a client asserting its own identity is meaningless rather than hostile, and erroring would break every existing submitter the moment auth is switched on.
- Owner defaults to Submitter when the client supplies none.
- Owner equal to self (case-insensitive) is accepted; the principal's own canonical casing is stored, not the client's.
- Owner other than self requires policy.JobsSubmitAs, else 403.
An owner naming no known user is rejected with 400 when
auth.validate_job_owner is on (the default).
WebSocket delivery is scoped the same way as REST. Per-job subjects
(jobs/{id}/tasks, tasks/{id}/logs) are authorized once at subscribe time;
the global jobs subject is filtered per event. A client that cannot resolve a
job's owner receives nothing for it rather than everything. The diagnostics
subject is gated separately at subscribe time on diagnostics.read, so a
read-only or user principal is refused the server-log feed on the socket
exactly as it is on GET /api/v1/diagnostics/logs.
Task isolation¶
Job identity (above) is an HTTP-layer concept: it records who owns a job in
the database. Task isolation is the OS-layer counterpart: it controls which
OS account a task's process actually runs as on the worker. The two are
independent — a job's Owner does not have to match, and generally will not
match, the run_as_user its queue configures.
A queue may set run_as_user (and optionally run_as_group) via
POST /api/v1/queues or PUT /api/v1/queues/{id}. When set, the scheduler
attaches that username only — never a credential — to every task
assignment for that queue's tasks (AssignMsg.Isolation /
protocol.IsolationSpec, internal/worker/protocol/protocol.go). This is
deliberate: worker↔server transport has no authentication at all today (see
Known gaps), so nothing secret can be allowed to travel on
that channel. The worker resolves the username to a real OS credential
locally and runs both job-code launch sites under it — an OpenJD
environment's onEnter/onExit actions and a task's own actions — with a
filtered environment and a session working directory private to that user.
This runs on both POSIX (Linux/macOS) and Windows. On Windows the worker
itself must run as a service under LocalSystem (or hold
SeAssignPrimaryTokenPrivilege directly) — an elevated Administrator shell
does not have that privilege by default. See
docs/worker-configuration.md for the isolation
worker-config block, the per-platform requirements, and the
environment-allowlist mechanics.
Before enabling run_as_user on any queue, see the worker upgrade requirement
documented in docs/configuration.md:
old workers silently ignore isolation, creating partial and silent enforcement
across a mixed-version farm.
isolation.manage is a separate, admin-only permission¶
Setting a queue's run_as_user/run_as_group — including sending an explicit
null to clear a previously-set value — requires the isolation.manage
permission. infra.manage, which the operator role holds and which governs
every other queue field, is deliberately not sufficient. Choosing the OS
identity that arbitrary, worker-supplied job code executes under is an
escalation surface, not ordinary queue configuration, so it gets its own gate
held only by admin (see the role matrix above).
Omitting run_as_user/run_as_group from a PUT body is not the same as
sending them. A PUT is normally full-replace, but these two fields are a
deliberate exception: omitting the key preserves whatever is already stored
and requires no permission at all, while a body that includes the key —
even as null — is treated as an attempt to change it and requires
isolation.manage. Without this exception, an operator with only
infra.manage editing a queue's priority would silently strip out an admin's
run_as_user on every save, since the operator's client has no reason to
round-trip a field it cannot see the point of. (Server-side, this preserve
semantics is implemented as a single atomic UPDATE rather than a
read-modify-write, to close a lost-update race between a preserving write and
a concurrent admin write.)
Enabling isolation raises the worker daemon's own privilege¶
This is the single most important fact about this feature, and it is
counter-intuitive: turning isolation on makes the worker's own daemon process
more privileged, not less. On POSIX, dropping privileges to become another
user (setuid/setgid/setgroups) is itself an operation that requires
starting as root — isolation.Provider.Capable() returns an error unless the
worker's effective uid is 0. An operator who runs sqi-worker unprivileged
today and enables isolation expecting a pure reduction in the blast radius of
job code has it backwards: the trade is that the daemon gains root so that
individual tasks can lose it. Weigh that trade for your environment before
enabling isolation.required or configuring any queue's run_as_user — it is
not a strict security improvement over an unprivileged, unisolated worker; it
is a different risk shape (one root daemon, many unprivileged task processes)
traded for another (one unprivileged daemon, all tasks running as that same
account).
Privileged accounts and groups are refused outright¶
Independent of, and in addition to, the supplementary-group stripping below,
isolation.Provider.Resolve refuses several requests outright rather than
silently narrowing them:
run_as_usernaming a known-privileged account —root,Administrator,Administrators,SYSTEM,LocalSystem,NETWORK SERVICE, andLOCAL SERVICE— by name, and any account whose uid is 0, regardless of name. Name matching is case-insensitive and first strips aDOMAIN\/HOST\/.\qualifier and a trailing@domainUPN suffix, so.\Administrator,CORP\AdministratorandAdministrator@corp.example.comare all the same refused name. The list is not configurable: an operator-overridable version would defeat the point, since the whole risk is a queue pointing at an account more privileged than the daemon.run_as_groupnaming a known-privileged group —root,wheel,admin,sudo,sudoers,adm,docker,disk,shadow,staff,administrators— by name. This is a check against the group you explicitly asked for, not the target account's ambient memberships (see the next section) — a queue cannot selectdockerorwheelas the isolation group even if the target account happens to belong to it.- A
run_as_useraccount whose primary group is gid 0 — refused even whenrun_as_groupis not set at all, since the primary group is determined by the account, not by queue config, and gid 0 is refused unconditionally regardless of which name a platform gives it (rooton Linux,wheelon macOS/BSD).
This is why wheel never appears as a group job code can reach on macOS/BSD:
it is gid 0 there, so it is refused both as an explicit run_as_group
target and, via the gid-0 check just above, as anyone's primary group.
A target account's existing group memberships reach job code¶
Isolation strips gid 0 (root) from a target account's supplementary group
list unconditionally, wherever it appears. It does not strip any other,
named group the account already belongs to — docker, disk, shadow, or
any in-house privileged group. This is by design, not an
oversight: those memberships are the account's own, pre-existing access, and
supplementary groups on a render-farm account typically exist specifically to
grant project-storage access (an NFS-exported group, for example). Silently
stripping every named group to be "safe" would silently break exactly the
access those groups exist to provide, on every job, with no way to tell
whether a given group was load-bearing.
The consequence is the operator's to manage, not sqi's: do not point a
queue's run_as_user at an OS account that belongs to docker — group
membership in docker is a well-known one-step escalation to root (a
container can bind-mount the host root and chroot into it), so isolating into
that account grants job code exactly the privilege isolation exists to deny.
The same caution applies to disk and shadow. Audit the target account's
group memberships (id <user>) before assigning it to a queue, the same way
you would audit any account you grant OS-level access to.
Environment allowlist and env_passthrough¶
An isolated task's environment is filtered when its session is created:
starting from a minimal base (PATH, HOME/USERPROFILE, TMPDIR, and a
handful of others rewritten to the target user rather than the daemon's own),
only daemon-environment variables whose name matches a
isolation.env_passthrough glob are added. This governs only what the
isolated task inherits from the worker daemon's own environment — variables a
job supplies itself (an OpenJD Environment.variables block, an
openjd_env export, a task template variable) always pass through untouched,
regardless of env_passthrough, because they are the job's own data, not
daemon leakage.
A render farm's licensing model almost always needs some of this escape hatch — worked example:
isolation:
env_passthrough:
- "foundry_LICENSE"
- "ARNOLD_LICENSE"
- "solidangle_LICENSE"
A broad glob defeats the allowlist entirely. env_passthrough: ["*"] or
even something that looks narrower, like "*KEY*", re-opens exactly the
daemon-environment leak the filter exists to close — every variable the
daemon process happens to have (credentials, tokens, internal service
addresses) now reaches job code again. Write globs as specific as the actual
variable names in use; a missing license variable in a task's log is the
expected, and correct, symptom of an allowlist that is too tight — widen it
by name, not by wildcard. See
docs/worker-configuration.md for the full
isolation config reference.
Login & sessions¶
POST /api/v1/auth/login takes {"username", "password"} and, on success,
mints a server-side session and sets it as a cookie via Set-Cookie. On
failure it returns a 401 with an identical body whether the username is
unknown, the password is wrong, or the account is disabled — deliberately, so
the endpoint can't be used to enumerate valid usernames. (The unknown-user
path still runs a dummy argon2id verify so it costs about the same time as a
real check — otherwise the response latency alone would leak which
usernames exist, even with identical bodies.)
The cookie:
| Attribute | Value |
|---|---|
| Name | auth.session.cookie_name, default sqi_session |
HttpOnly |
always set — client-side JS can never read it |
SameSite |
always Lax |
Secure |
per auth.session.cookie_secure — "auto" (default), "true", or "false" |
Max-Age |
auth.session.ttl in seconds, default 168h (7 days) |
cookie_secure is a 3-valued string, not a bool, because sqi's default
deployment posture is a trusted, plain-HTTP LAN (http.addr defaults to
0.0.0.0:8080, no TLS). "auto" sets Secure when the request arrived over
TLS or carries X-Forwarded-Proto: https, which is right behind a
TLS-terminating proxy that sets that header — but an operator on plain HTTP,
with no such proxy, needs to be able to force Secure off explicitly rather
than depend on "auto" guessing right; hence "false" as an explicit option,
and "true" for the reverse (force it on even if the auto-detection would
miss it).
Session expiry is absolute, not sliding: auth.session.ttl sets the
lifetime from creation, and using a session does not extend it. A session
becomes invalid at exactly created_at + ttl regardless of activity; the
user must log in again.
POST /api/v1/auth/logout deletes the session server-side and clears the
cookie; it always returns 200 with a JSON body (even if the cookie was
already invalid). The body is {} for a local logout, and carries
redirect_url — the identity provider's RP-initiated logout URL — when SSO is
configured with auth.oidc.logout_mode=provider.
GET /api/v1/auth/me returns the current Principal — 401 if
unauthenticated, otherwise the resolved subject/display name/roles/kind. This
is the single endpoint the web UI polls to decide shell-vs-login; see
docs/web-development.md.
Deleting a user (DELETE /api/v1/users/{id}) cascades to its sessions at the
database level (sessions.user_id REFERENCES users(id) ON DELETE CASCADE) —
every session belonging to a deleted user is revoked immediately, in the same
transaction as the delete.
First-admin bootstrap¶
When auth.enabled=true and the users table is empty, sqi-server seeds a
single admin account at startup from auth.bootstrap.username /
auth.bootstrap.password (env SQI_AUTH_BOOTSTRAP_USERNAME /
SQI_AUTH_BOOTSTRAP_PASSWORD):
SQI_AUTH_ENABLED=true \
SQI_AUTH_BOOTSTRAP_USERNAME=admin \
SQI_AUTH_BOOTSTRAP_PASSWORD=change-me-after-first-login \
./bin/sqi-server serve
This creates one role: "admin" account named admin. Log in with it, then
immediately set a real password via PUT /api/v1/users/{id}/password (or the
web Admin → Users page) — the bootstrap password is meant to be transient.
Bootstrap behavior:
- Idempotent and non-destructive. It only runs when the
userstable is empty. Once any user exists — bootstrapped or otherwise — it is a permanent no-op, even if the bootstrap env vars are still set; it never overwrites an existing account's password. - Empty and unconfigured does not fail closed. If auth is enabled, the
table is empty, and neither bootstrap variable is set, the server logs a
WARN("auth is enabled but no users exist and no bootstrap credentials are configured…") and still boots successfully. There is simply no one who can log in yet — the server is up but practically unusable for any authenticated route until an operator sets the bootstrap env vars and restarts, or otherwise seeds a user directly. - A half-set pair is a startup validation error, not a warning: setting
only
auth.bootstrap.usernameor onlyauth.bootstrap.password(e.g. a typo'd env var name) fails config validation and the server does not start — this guards against silently creating (or trying to create) an admin with an empty password.
CSRF & CORS¶
A session cookie is an ambient credential: once set, the browser attaches
it automatically to every request to this origin — including ones a
malicious third-party page initiates (classic CSRF), and including the
WebSocket upgrade (cross-site WebSocket hijacking is the same vector applied
to /api/v1/ws). A stateless Authorization header client has no such
problem, because nothing attaches it automatically; cookies need explicit
defenses that header-based auth doesn't.
sqi's model, mounted only when auth is enabled:
- The cookie itself carries
SameSite=Lax, which already blocks the cookie from being sent on most cross-site subrequests (though not top-level navigations, whichLaxstill allows). - On top of that,
internal/middleware/csrf.goenforces an Origin check on unsafe methods for cookie-authenticated requests:GET/HEAD/OPTIONSare never checked (they must not have side effects); a request that does not carry the session cookie passes through unchecked (it isn't cookie-authenticated, so there's no ambient-credential vector to guard); an unsafe-method request that does carry the cookie must present anOrigin(falling back toReferer's origin) that is either same-origin or in an explicit allow-list — otherwise it's rejected with403. A cookie-bearing unsafe request with neither header at all is rejected too: browsers always send one or the other on such requests, so their total absence isn't trusted. - CORS enables the credentialed cross-origin case at the browser level:
Access-Control-Allow-Credentialsis only ever sent when auth is enabled. A wildcard*origin can never be combined with credentials (browsers reject the combination outright), so if the configured origin list still contains"*"once auth is enabled, the router drops the wildcard and logs an error rather than silently disabling credentials or leaving the combination broken.
The normal deployment is same-origin — sqi-server serves the built web
UI itself (web/dist/ embedded via internal/ui) — so none of this affects
the shipped UI in its default configuration. It only matters for a
separately-hosted UI (a different scheme/host/port) that wants to call a
sqi-server instance's cookie-authenticated API cross-origin.
Note — a WebSocket is authenticated once, at upgrade. Authentication and owner-scope resolution happen a single time, when
/api/v1/wsis upgraded; they are not re-evaluated for the life of the connection. Disabling the account, revoking its session, or changing its role does not drop a live WebSocket — the connection keeps delivering until the client closes it or it hits the idle timeout (wsIdleTimeout, 5 minutes, ininternal/api/ws.go). The exposure is bounded to that one connection's own already-authorized scope: it can only keep receiving what it was authorized to receive at upgrade, with no cross-user leakage or privilege escalation. REST is different — every request re-authenticates and re-authorizes, so a revoked credential stops working on the next call.
Configuring allowed origins¶
Name the origins a separately-hosted UI will call from, through any of the three config layers (later beats earlier):
http:
cors_origins:
- "https://ui.example.com"
- "http://localhost:5173"
SQI_HTTP_CORS_ORIGINS="https://ui.example.com,http://localhost:5173"
sqi-server serve --http-cors-origins=https://ui.example.com
Each entry must be scheme://host[:port] or "*". A trailing slash, a
path, a query, a fragment, or embedded whitespace is rejected at startup
with a http.cors_origins validation error — go-chi/cors could never match
such a value, so a typo fails loudly at boot instead of silently at request
time. Wildcard patterns other than the bare "*" are rejected too: an
entry containing an embedded * (e.g. https://*.example.com or
https://app.example.com*) is refused at startup, because go-chi/cors would
otherwise honor it as a prefix/suffix match — with credentials, once auth is
enabled — letting an attacker-registrable origin (like
https://app.example.com.evil.io) ride a victim's session cookie. Name every
allowed origin explicitly.
Leaving the list empty keeps the previous default of ["*"]. The
wildcard-drop above still applies: with auth enabled, "*" — whether
explicit or defaulted — is dropped and credentialed cross-origin requests
are refused. A separately-hosted UI must therefore name its origin
explicitly here. Same-origin deployments need none of this.
Headless / SDK auth¶
As of component A2, sqi has an issuable headless credential: API keys,
covered in full below. internal/server/server.go's selectAuth now wires
auth.Chain(keyAuthn, sessAuthn) — a Bearer API key is tried first, and the
session cookie is the fallback for browser requests.
The Python SDK (clients/python) was already wired ahead of time for this:
SqiClient(base_url, token=...) sends Authorization: Bearer <token>,
falling back to the $SQI_TOKEN then $SQI_API_KEY environment variables
when token isn't passed explicitly, and a 401/403 response raises the typed
SqiAuthError (clients/python/src/sqi_client/errors.py). The submitter
framework (clients/submitter) resolves a key the same way, one tier
simpler: an api_key argument, then $SQI_API_KEY, then the api_key key
in ~/.sqi/submitter.json — see clients/submitter/README.md. With
auth.enabled=true, issue yourself a key (POST /api/v1/api-keys or the web
Admin → API Keys page) and pass it via token=/$SQI_TOKEN/$SQI_API_KEY
(SDK) or api_key=/$SQI_API_KEY/submitter.json (submitter) to unblock
headless usage.
API keys¶
An API key is a per-user, sqi_-prefixed Bearer credential for scripts, the
SDK, and DCC submitters — the machine/headless counterpart to the browser
session cookie above.
Issuance. POST /api/v1/api-keys with {"name", "expires_at"?} creates
a key owned by the calling principal and returns it once
(internal/api/apikeys.go):
{
"id": "…", "name": "render farm", "prefix": "sqi_AbCdEfGh",
"expires_at": null, "last_used_at": null, "created_at": "…",
"secret": "sqi_AbCdEfGh1234…"
}
The secret field — the full raw key — is present only in this create
response. Every other response (GET /api/v1/api-keys, the list on the web
Admin → API Keys page) omits it and shows the prefix instead, so copy the
secret down before navigating away; it cannot be recovered later, only
revoked and reissued.
Presentation. Clients send Authorization: Bearer <key>. Keys are the
credential for headless/machine access; browser sessions stay cookie-based as
described above — see Headless / SDK auth for how the
SDK and submitter pick a key up from an argument, environment variable, or
settings file.
Storage & security. The raw key is generated as 256 bits of random data,
base64url-encoded, and prefixed sqi_. Only its hex SHA-256 digest
(internal/auth/password.HashToken) is stored in the api_keys table —
never the raw key — alongside a 12-character display prefix taken from the
start of the raw key, used to tell keys apart in the list view without
revealing the secret. last_used_at is updated on successful authentication,
throttled to at most once per minute per key so a busy key doesn't write on
every request (internal/auth/apikey.Authenticator's touchThreshold).
Optional expires_at is enforced at authentication time, not just at
creation: GetAPIKeyByTokenHash only matches rows that are unexpired (and
unrevoked), so an expired key stops authenticating the instant it lapses,
with no separate sweep required.
Revocation. DELETE /api/v1/api-keys/{id} is a soft revoke — it sets
revoked_at rather than deleting the row — and takes effect immediately:
the same "unrevoked" filter that enforces expiry means the very next request
bearing that key is rejected.
Scope. POST/GET/DELETE /api/v1/api-keys are self-scoped: they
resolve the caller's own user id and only ever see or touch that user's
keys (apikeys.self, held by every role).
Admins additionally hold apikeys.admin, which unlocks two cross-user
routes:
| Route | Effect |
|---|---|
GET /api/v1/users/{id}/api-keys |
List that user's keys (metadata only — never a secret). |
DELETE /api/v1/users/{id}/api-keys/{keyId} |
Revoke one of that user's keys. |
There is deliberately no admin create. An admin may see and revoke another person's keys, but minting a credential someone else is accountable for is a materially different act, so no route offers it.
Revocation stays owner-scoped underneath: a keyId that does not belong to
the named user returns 404, so that existing scoping is the
authorization check rather than a separate ownership branch that could be
forgotten. The web surfaces this at /users/{id}/api-keys, reachable from
the per-row "API keys" action on Admin → Users.
Auth-off behavior. With auth.enabled=false, every request is the
anonymous superuser principal, which has no real user id to own a key
against, so all three /api-keys endpoints reject with 409 Conflict
("API keys require authentication to be enabled") rather than silently
operating on a fake account — consistent with the rest of the auth-off
posture elsewhere in this doc.
CSRF. A Bearer request carries no cookie, so the CSRF guard in CSRF & CORS never engages for it: that guard only inspects requests that carry the session cookie in the first place, and a Bearer-authenticated request has nothing for it to check.
LDAP / Active Directory¶
As of component C1, sqi-server can verify passwords against an LDAP or
Active Directory server instead of its own store. Enable it with
auth.ldap.enabled on top of auth.enabled — LDAP is an addition to the
auth system, not an alternative to it, and an auth-off server never contacts
a directory whatever auth.ldap.* says. Every field is catalogued in
docs/configuration.md.
It attaches at login, not at every request¶
LDAP is a login-time credential verifier, not an Authenticator. This is
a deliberate departure from the obvious design (an Authenticator
implementation alongside the session and API-key ones): a per-request
authenticator would mean a directory bind on every API call, which turns
the DC into a hard dependency of every page load and every SDK poll.
Instead, POST /api/v1/auth/login checks the password against the directory
and then mints the same server-side session a local account gets. After
that moment nothing is LDAP-specific: auth.Chain(apikey, session) is
untouched, and the session cookie, its TTL, roles and permissions, job owner
binding, and WebSocket scoping all behave exactly as documented above. A
directory outage blocks new logins; it does not disturb sessions already
issued.
The practical consequence is revocation lag, below.
Per-account routing¶
Each account carries users.auth_source — local, ldap, or (from C2)
oidc. It is set when the account is created and is immutable — no route
can change it. POST /auth/login reads it and consults exactly one backend:
the stored argon2id hash, or the directory. Never both, never in sequence. An
oidc account has no password path at all; it signs in through
the SSO routes.
Not chaining the two is a security decision, not an optimization. If a failed local login fell through to a directory bind, every wrong password in sqi would become a failed bind against a real DN — and in Active Directory repeated bad binds lock the directory account. A brute force against sqi would become an org-wide denial of service.
The same routing means a local account shadows a same-named directory
account outright. If alice exists locally, a directory alice can never
log in, and the directory is not even contacted. The reverse — a directory
login adopting an existing local record — is refused rather than allowed:
adopting would mean anyone who can create a directory account named admin
inherits the local admin. Both cases return the same generic 401, so the
users page (Admin → Users) shows each account's source; that column is
usually the fastest way to see why a login is being refused.
Just-in-time provisioning¶
An unknown username with LDAP enabled is a provisioning event: sqi binds
against the directory, maps a role from the returned groups, and creates a
local record with auth_source: ldap. There is no import step and no
pre-registration. The stored password_hash is an unusable placeholder, not
a copy of the directory password.
The display name is read from display_name_attr once, at creation, and
never re-synced. That is what makes PATCH /api/v1/auth/me meaningful for
a directory user — a self-service display-name edit persists instead of being
overwritten at the next login. The trade is that a name changed in the
directory (a marriage, a correction) does not propagate; an admin edits it,
or the user does.
Accounts are matched on a stable identifier, not a username¶
unique_id_attr is required whenever LDAP is enabled and has no
default. Set it to objectGUID on Active Directory, or entryUUID on
OpenLDAP and other RFC 4530 servers. No single value is correct on both, and
guessing on a server that exposes both would silently pick the wrong one.
sqi stores that value in users.external_id and matches every later login on
it, never on the username. A username is not an identity: directories recycle
login names and email addresses, so a new hire given a departed admin's name
would otherwise log straight into that admin's account — same role, same owned
jobs, no error anywhere. A rename at the directory is the mirror failure, and
would orphan the account and provision a duplicate.
Two consequences worth stating plainly:
- A directory rename is transparent. The entry keeps its identifier, so the same sqi account is reached under the new name.
- A recycled username is refused, not adopted. A new directory entry wearing an old name has a new identifier, so provisioning runs and collides on the taken username. The login fails until an operator renames or removes one of the two accounts. Refusal is the intended outcome.
Active Directory returns objectGUID as raw binary, which sqi hex-encodes
before storing. That encoding is permanent — changing it would orphan every
account already stamped.
Upgrading from an earlier sqi¶
LDAP accounts provisioned before identifier matching shipped carry an empty
external_id. They cannot log in once matching is in effect: the identity
lookup misses, provisioning collides on the username, and the result is a
permanent 401.
This is deliberate. Adopting a row whose stored identifier is empty is
username matching under another name and would preserve the recycling hazard
indefinitely, for exactly the long-lived, often privileged accounts most
likely to predate the upgrade. Delete and recreate such accounts; the next
login re-provisions them with the directory's identifier. The server logs an
ERROR naming the account and this remedy each time one is refused — the 401
itself is identical to every other login failure by design, so the log is the
only signal.
Both bind modes¶
The two modes are mutually exclusive; setting user_dn_template selects
template bind, and config validation rejects any attempt to combine it with
bind_dn/base_dn.
Search-then-bind (the usual Active Directory shape): a service account searches for the user's entry, then sqi binds as the DN it found.
auth:
enabled: true
ldap:
enabled: true
url: "ldaps://dc01.example.com:636"
bind_dn: "CN=sqi-svc,OU=Service Accounts,DC=example,DC=com"
bind_password: "…" # SQI_AUTH_LDAP_BIND_PASSWORD in practice
base_dn: "DC=example,DC=com"
user_filter: "(sAMAccountName=%s)"
username_attr: "sAMAccountName"
display_name_attr: "displayName"
unique_id_attr: "objectGUID"
nested_groups: true
role_map:
- group: "CN=Farm Admins,OU=Groups,DC=example,DC=com"
role: admin
- group: "CN=Farm Operators,OU=Groups,DC=example,DC=com"
role: operator
default_role: "read-only"
Template bind (typical OpenLDAP, no service account): sqi builds the
user's DN directly and binds as them, then reads their own memberOf.
auth:
ldap:
enabled: true
url: "ldap://ldap.example.com:389"
start_tls: true
user_dn_template: "uid=%s,ou=people,dc=example,dc=com"
username_attr: "uid"
display_name_attr: "cn"
unique_id_attr: "entryUUID"
role_map:
- group: "cn=farm-admins,ou=groups,dc=example,dc=com"
role: admin
default_role: "read-only"
In both modes %s is the username, escaped for its context (filter escaping
for user_filter, DN escaping for user_dn_template) before substitution.
Anonymous search is supported. Setting base_dn with no bind_dn runs
the search on an anonymous connection, which is what a world-readable
directory wants. Setting bind_password without bind_dn is rejected at
boot instead: the password would be silently discarded and the search would
go out anonymously with nothing in the logs to say so.
Alias / UPN login works. With user_filter: "(userPrincipalName=%s)" and
username_attr: "sAMAccountName", a user typing alice@example.com is
provisioned as alice and is recognized as that same account on every
subsequent login. The two spellings deliberately do not create two records —
and since accounts match on the identifier rather than the name, that holds
across a directory rename too.
username_attr should still name a directory-controlled, unique attribute.
It is no longer what sqi matches an account on — unique_id_attr is — but it
is the value written to users.username, which is unique in sqi's own store,
and it is what binds to Job.Owner/Job.Submitter. Point it at something
users can edit themselves — mail is the obvious trap — and one user can
claim the name another account already holds, which blocks that account's
next login on a username collision until an operator renames one of the two
rows. That is a denial of service, and a confusing one to diagnose; it is not a
privilege escalation, because a collision is refused rather than adopted and no
account is ever inherited. sAMAccountName and uid are the right kind of
attribute; a self-service directory field is not.
Nested groups are search-mode only. nested_groups: true expands
transitive membership via the AD matching-rule OID; template bind reads the
flat memberOf attribute and cannot do it, so config validation rejects the
combination rather than silently ignoring it. Two things are worth knowing
about the expansion:
- It runs on the service-account connection, not the user's, so the service account needs read access to the group tree.
base_dnmust cover the group tree, not just the user tree. The expansion reusesbase_dnas its search base. Scoping it narrowly — saybase_dn: "OU=Users,DC=example,DC=com"while groups live underOU=Groups— produces a search that succeeds and matches nothing, with no error to notice. Setbase_dnto a subtree containing both (commonly the domain root,DC=example,DC=com).- If it fails or returns nothing while flat
memberOfhad values, sqi falls back to flatmemberOfand logs aWARNrather than failing the login. That is the safer failure for availability, but it means a user who holdsadminonly through a nested group can be silently granted a lower role for that session. The warning in the server log is the only signal. If your admin group is nested, treat thatWARNas an alert. - The matching rule is Active-Directory-only. Verified against OpenLDAP
2.6: it does not reject the unknown rule, it rewrites the filter to
(?=undefined)and answers success with zero entries. So on a non-AD directorynested_groups: truenever expands anything — it just takes the fallback path above on every login, logging aWARNeach time. Leave itfalseunless you are actually on AD.
Your directory must populate memberOf¶
Both bind modes read group membership from the memberOf attribute on the
user's own entry. Active Directory populates it natively. OpenLDAP does
not — it requires the memberof overlay to be configured on the database:
overlay memberof
memberof-group-oc groupOfNames
memberof-member-ad member
memberof-memberof-ad memberOf
Without it, every search and bind succeeds, the user authenticates, and sqi
sees no groups at all — so every account silently lands on default_role.
There is no warning for this, because "this user is in no groups" is a
legitimate state indistinguishable from "the directory never populates
memberOf". If every LDAP user is arriving with your default_role, check
the overlay before anything else.
Verify from the command line before configuring sqi — the attribute must come back for a user you expect to be in a group:
ldapsearch -x -H ldap://ldap.example.com -D "<bind_dn>" -W \
-b "dc=example,dc=com" "(uid=alice)" memberOf
The account that reads it must also be allowed to: in search mode that is the
service account (or the anonymous connection, if bind_dn is unset), and in
template mode it is the user themselves, reading their own entry.
Group → role mapping¶
role_map is an ordered list of group-DN → role rules and the first
match wins — order is how you express precedence, so put admin above
operator. A role naming anything other than admin, operator, user, or
read-only fails config validation at boot rather than falling through to
default_role, which would hand out the wrong privileges with no error to
explain it.
default_role applies when no rule matches. Setting it to empty rejects
the login entirely, which is how a deployment requires group membership to
sign in at all. The default is read-only.
role_source — who owns a user's role¶
| Value | Role on login | PATCH /users/{id} role edit |
|---|---|---|
directory (default) |
Recomputed from groups on every login | 409 Conflict |
local |
Seeded from groups at JIT-create only | Allowed |
One value drives both halves, and it must stay that way. Splitting them produces the worst outcome available here: an admin edits a role, the API returns 200, and the next login silently reverts it with nothing to indicate which value is real.
Switching local → directory is not a neutral change. At each user's next
login their role is recomputed from their groups, so every manual role
assignment is overwritten, quietly and one user at a time as people log
back in. The server logs the active role_source at boot so there is at
least a record of when the mode changed.
Keep a local admin account¶
In directory mode, a local admin account is a requirement, not a
suggestion. Consider a renamed or deleted admin group: at their next login
every LDAP admin maps to default_role and is demoted. There is now no admin
in the system, and no way to fix it through the UI — role edits on directory
accounts are 409 by design, and there is nobody with users.manage left to
make them anyway.
A local account (the bootstrap admin is exactly this) is unaffected by any directory-side change and can always log in and repair the configuration.
Known gap: the last-admin guard does not cover this. It refuses the last admin's deletion, disablement, or demotion through the API, which is the only place it can see. A group renamed in Active Directory reaches the same end state through a path the guard has no visibility into. Nothing in sqi can detect that; the local admin account is the mitigation.
Revocation lag¶
Login is the only moment sqi talks to the directory, so a user disabled or
deleted in the directory keeps their sqi session until it expires —
auth.session.ttl, 7 days by default. They cannot get a new session, but
the one they hold keeps working.
Shorten auth.session.ttl if that window matters. To cut a user off
immediately, disable the account in sqi (PATCH /api/v1/users/{id} with
disabled: true): the session authenticator re-checks the user record on
every request, so it takes effect on the next call, and the local disabled
flag overrides the directory for both session checks and login.
sqi deliberately does not auto-disable accounts when a directory lookup fails. A DC outage would otherwise mass-disable the entire farm — an availability failure far worse than the lag it would close.
Timing¶
The 401 bodies are byte-identical across every failure path — unknown user,
wrong local password, wrong directory password, directory unreachable, no
role matched, collision with a local account, disabled account,
unrecognized auth_source. That is tested. The latencies are not
identical, and cannot be made so.
The local-only equalization described under
Login & sessions works because both sides of that
comparison are argon2id derivations of matching cost. A directory bind is a
network round trip; no local computation can be made to match it. So an
observer timing /auth/login can distinguish, at minimum, an immediate
rejection (a locally disabled account, a shadowed local account) from a local
password check from a directory round trip. Whether that yields useful
account enumeration depends on your directory's own timing behavior — a bind
against a nonexistent DN typically differs from one against a real DN, and
that is the directory's behavior, not something sqi can conceal.
The mitigation for the timing channel is rate limiting — but read the next section before assuming the shipped limiter provides it.
Brute force: what sqi actually does, and does not, protect against¶
There is no login-specific throttle. The only control is the generic
per-IP token bucket applied to all of /api/v1 (internal/api/router.go):
20 requests/second sustained, burst 40, keyed on client IP. Nothing
counts failures, nothing backs off after a wrong password, nothing locks an
account, and nothing distinguishes /auth/login from a job listing.
Twenty per second is roughly 1.7 million login attempts per day, per source IP. Treat that as no brute-force control at all. It is a capacity guard that keeps one client from saturating the API; it was never a credential defense and does not become one because the login route sits behind it.
Failed logins against directory accounts hit your directory. For an
account whose auth_source is ldap, every wrong password produces a real
bind against a real DN. In Active Directory that increments
badPwdCount, so an unauthenticated attacker who can name your users
can drive them into domain-wide lockout — locking them out of Windows,
email, and everything else, not just sqi. That is the same end state the
per-account routing rule was designed to avoid for
local accounts, reached instead through the front door.
Unknown usernames also cost the directory. With LDAP enabled, an
unrecognized username takes the just-in-time provisioning path, which
consults the directory before failing. So /auth/login will happily convert
unauthenticated HTTP requests into domain-controller round trips — an
amplifier aimed at your DC.
If any of this matters to you, put a real control in front of sqi. A
login-specific throttle, fail2ban on the access log, or a WAF rule on
POST /api/v1/auth/login — something that counts failures per account and
per source and backs off. sqi does not ship one today, and the generic
limiter should not be mistaken for one. Do not expose an LDAP-enabled
deployment to the internet without it.
A hung directory¶
go-ldap offers no context-aware dial, so a login in progress cannot be
aborted by request cancellation — a client that gives up does not free the
server-side attempt. What bounds it is auth.ldap.timeout (default 10s),
applied to the TCP connect and to each subsequent request leg. Set it to
something you are willing to have a request block for; the default is not a
generous one by accident.
Directory accounts have no local password¶
There is no password in sqi for a directory account to change, so the password routes refuse rather than pretend:
| Route | On an ldap account |
|---|---|
PUT /api/v1/users/{id}/password (admin) |
409 Conflict |
PUT /api/v1/auth/password (self-service) |
409 Conflict |
PATCH /api/v1/auth/me (display name) |
Works |
Both password routes key on "not a local account", so an
SSO account behaves identically. That single rule is also what
the users API reports as password_editable — the companion to
role_editable — so a client can hide its set-password control instead of
letting someone type a password and discover the 409. Field and guard are
computed from one server-side predicate and cannot drift apart.
The admin-side 409 also closes a real hole: without it, an admin could write
a genuine argon2id hash onto a directory account. Login routes on
auth_source and would not consult it today, but leaving a usable credential
lying in the row is the kind of thing a future refactor turns into a bypass.
PATCH /auth/me is deliberately not guarded, for the reason given under
just-in-time provisioning: the display name is
seeded from the directory once and never re-synced, so a self-service edit
has nothing to conflict with and persists.
Turning LDAP off strands the accounts it created¶
Disabling auth.ldap.enabled does not clean up after itself. Every row
already provisioned with auth_source: ldap stays exactly as it is, and
every route that touches it now refuses:
Action on a stranded ldap account |
Result |
|---|---|
| Login | 401 — no verifier is configured, so no credential can satisfy it |
PUT /users/{id}/password |
409 — a directory account has no local password |
PATCH /users/{id} role edit (under role_source: directory) |
409 — the role is directory-owned |
The account is unusable and unrepairable in place. Delete and recreate it
as a local account — that is the only remedy, and it is deliberate: there
is no conversion endpoint, because flipping an account's auth_source is
exactly the operation that would let a directory entry inherit a local
account's privileges (see per-account routing). A
missing convenience is the correct trade against a privilege-escalation
primitive.
Note that display names and role assignments do not survive that round trip, and the user's sessions die with the old row. If you are migrating away from LDAP, plan it as a re-provisioning exercise, not a config flag.
How this is tested¶
Most tests of this feature drive a fake LDAP connection. Those cover sqi's own logic thoroughly — routing, provisioning, role mapping, collisions, the equalized 401 — but a fake cannot catch a mistake in the go-ldap wire usage: a wrong search scope, a misnamed attribute, a filter a real server rejects, or a server that answers an unsupported request in a way the fake never would.
So test/integration/ldap_test.go runs the whole login path against a real
OpenLDAP server in a throwaway container, in every supported configuration:
search-then-bind with a service account, template bind, and anonymous
search-then-bind — plus group→role mapping and precedence, default_role,
JIT provisioning, role re-sync under role_source: directory, filter and DN
injection, and empty-password binds. It runs in CI on every change, on both
amd64 and arm64.
make test-ldap # needs Docker (or colima/podman); skips cleanly without it
Point it at a directory you already have — including a real Active Directory —
with SQI_TEST_LDAP_URL, and it uses that instead of starting a container. The
fixture tree it expects is the seedLDIF constant in that file.
That suite exists because of a bug it now guards: OpenLDAP does not reject the AD-only nested-group matching rule, it answers success with zero entries, and an earlier revision let that empty result replace a user's real groups and silently demote them. No fake reproduced it.
Still test a new deployment against your own directory before relying on
it. Directories differ in exactly the places this integration is sensitive
to: whether memberOf is populated, what the service account is allowed to
read, and how an unsupported matching rule is answered.
OIDC / SSO¶
As of component C2, sqi-server can sign users into the web UI through an
OAuth2/OpenID Connect identity provider — Keycloak, Microsoft Entra ID, Okta,
or anything else that publishes a discovery document. Enable it with
auth.oidc.enabled on top of auth.enabled; the block is inert unless both
are true. Every field is catalogued in
docs/configuration.md.
Scope: this is a browser login mechanism, and nothing else. The callback
mints the same server-side session cookie a password login mints, and
auth.Chain(apikey, session) is untouched. sqi never accepts a provider's
token as a per-request API credential, and there is no device-authorization
grant. Headless clients — the SDK, submitters, CI — keep using
API keys; SSO does not reach them.
The flow¶
Three routes, all public by necessity:
| Route | Purpose |
|---|---|
GET /api/v1/auth/providers |
What the login page renders: whether password login is offered, and the SSO button's label and login URL. Exposes neither the issuer nor the client secret. |
GET /api/v1/auth/oidc/login |
Starts the authorization-code flow (PKCE) and redirects to the provider. |
GET /api/v1/auth/oidc/callback |
Redeems the code, validates the ID token, resolves the account, mints the session, and redirects to /. |
They cannot sit behind the auth middleware — their whole purpose is to produce the principal that middleware would demand. What stands in for it:
- The flow state lives in an HMAC-signed,
HttpOnlycookie, carrying thestatevalue, the nonce, and the PKCE verifier. There is no server-side table and no cleanup job. The signing key is generated per boot and never persisted, so a server restart invalidates logins that are in flight — accepted, because the alternative is a persisted key. - The state cookie is cleared unconditionally, before any early return, so a replayed callback finds nothing waiting for it. The signed payload also carries its own issued-at, checked server-side, so an expired state is refused whether or not the browser honored the cookie's expiry.
- The post-login destination is a constant. No "return to" parameter is honored — an attacker-chosen destination on a page that has just minted a session is how open redirects become account takeovers.
- Every failure redirects to
/?sso_error=1and says nothing else. The reason goes to the server log only; a reason on the wire would turn the callback into an enumeration oracle. If SSO is failing, the server log is where the answer is —internal/auth/oidcitself logs nothing on token rejection, so the route logs every refusal.
Discovery is lazy: the issuer's document is fetched on first use, not at
boot, and is retried after a failure. A briefly unreachable provider must not
stop a render farm's scheduler from starting. Configuration faults still abort
boot — a missing client_id fails validation, an unreachable issuer does not.
A worked setup (Keycloak)¶
Keycloak is the provider sqi's integration test actually drives, so it is the
one worked here. Register a confidential client in your realm with the standard
flow enabled, a redirect URI of https://sqi.example.com/api/v1/auth/oidc/callback,
and — this part is easy to miss — a protocol mapper that puts group
membership into the groups claim. Keycloak emits no groups at all without
one, and a token with no groups still validates, so every user silently lands on
default_role.
auth:
enabled: true
oidc:
enabled: true
issuer: "https://keycloak.example.com/realms/farm"
client_id: "sqi"
# client_secret via SQI_AUTH_OIDC_CLIENT_SECRET
redirect_url: "https://sqi.example.com/api/v1/auth/oidc/callback"
scopes: ["openid", "profile", "email"]
username_claim: "preferred_username"
display_name_claim: "name"
groups_claim: "groups"
role_source: "directory"
role_map:
- group: "/farm-admins"
role: admin
- group: "/farm-operators"
role: operator
default_role: "read-only"
reauth_mode: "after_logout"
logout_mode: "local"
redirect_url must be the absolute URL of this server's callback route, and
must be registered at the provider byte-for-byte. client_secret is redacted
in sqi-server config print; prefer SQI_AUTH_OIDC_CLIENT_SECRET over writing
it to disk.
Claim → role mapping¶
role_map is an ordered list of group → role rules read from the claim
named by groups_claim, and the first match wins — order is how you
express precedence, so put admin above operator. This is the same mapper
LDAP uses (internal/auth/rolemap), so the two cannot drift apart on
precedence. A role naming anything other than admin, operator, user, or
read-only fails config validation at boot.
default_role applies when no rule matches; setting it empty rejects the
login entirely, which is how a deployment requires group membership to sign
in at all. The default is read-only.
"groups" is not a standard OIDC claim. Whether membership needs a scope,
a provider-side mapper, or both varies by provider, and there is no error when
it is absent — the token validates and the user lands on default_role. If
every SSO user is arriving with your default_role, check the claim before
anything else. (This is the OIDC counterpart of LDAP's
memberOf trap, and it fails the
same silent way.)
Just-in-time provisioning and identity¶
An unknown SSO identity is a provisioning event: sqi maps a role from the
claims and creates a local record with auth_source: oidc, its stored
password_hash an unusable placeholder. There is no import step.
Accounts are matched on the sub claim, never on the username — the same
rule and the same code path as
LDAP's stable identifier.
sub is the provider's stable, non-reusable subject identifier; it is stored
in users.external_id. A username or email changed at the provider therefore
reaches the same sqi account, and a recycled email address does not inherit
the account it used to belong to. An identity whose sub is empty is refused
outright, with an ERROR in the log, rather than falling back to name
matching — the fallback is the hazard.
Display name is read from display_name_claim once, at creation, and never
re-synced, so a self-service PATCH /api/v1/auth/me edit persists. The trade
is that a name changed at the provider does not propagate.
role_source — who owns an SSO user's role¶
| Value | Role on login | PATCH /users/{id} role edit |
|---|---|---|
directory (default) |
Recomputed from claims on every login | 409 Conflict |
local |
Seeded from claims at provisioning only | Allowed |
One value drives both halves, for the reason given under
LDAP's role_source: splitting them
means an admin edits a role, gets a 200, and the next login silently reverts
it.
auth.oidc.role_source and auth.ldap.role_source are tracked separately
— an operator may trust one provider's groups and not the other's — and the
role_editable field the users API returns is computed per account from
whichever source owns it.
Keep a local admin account. Every word of
Keep a local admin account applies here: rename
the admin group at the provider and, in directory mode, every SSO admin is
demoted to default_role at their next login, with role edits on those
accounts returning 409 and nobody left holding users.manage. A local account
is the only thing unaffected by a provider-side change.
Re-authentication and logout are two different things¶
They answer different problems and are configured independently. Conflating them is the usual mistake.
reauth_mode decides whether the next SSO login is allowed to be silent
— it sends prompt=login to the provider.
| Value | Behavior |
|---|---|
after_logout (default) |
Re-prompt only on the login that follows an explicit logout. |
always |
Re-prompt on every login. |
never |
Never re-prompt; silent re-login is always permitted. |
This — not logout_mode — is the answer to the shared-workstation problem.
Without it, clearing sqi's session while the provider still considers the
person signed in is precisely how the next person at that machine gets signed
in as the last one.
logout_mode decides whether logging out of sqi also ends the session at
the provider.
| Value | Behavior |
|---|---|
local (default) |
Clear the sqi session only. |
provider |
Also return the provider's RP-initiated logout URL, which the web navigates to. The sqi session ends immediately either way; the provider leg is where the browser goes next. Requires post_logout_redirect_url. |
local is the default because a provider logout signs the user out of every
company tool that trusts that provider — heavy-handed for someone mid-task
elsewhere.
Under provider, POST /api/v1/auth/logout returns
{"redirect_url": "…"}; every other logout returns {}. If the provider
advertises no end_session_endpoint, or discovery is unreachable, or the
advertised endpoint does not parse, sqi degrades to a local logout and logs
at ERROR — never silently.
sqi does not store ID tokens, so the end-session request uses client_id +
post_logout_redirect_uri and never id_token_hint. That is deliberate, and
measurement strengthened the case rather than weakening it:
- The token would be the first recoverable secret in the schema. Session tokens are stored as hashes, passwords as argon2id hashes. "Nothing in this database can be read back out and used" is an invariant worth more than a logout convenience.
- Keycloak accepts an expired ID token as a hint — verified well past
exp, and the session still died silently. A stored token is therefore a session-termination capability that does not decay; "it expires in minutes" is not a mitigation. - The hint works from a client holding no provider cookies. A leaked token can end that user's provider session from anywhere, by anyone holding it.
- The realistic leak path is accident, not theft — a debug log line dumping a session row, or a future session-listing endpoint. Avoiding it would require a redaction rule every future contributor remembers, which is where this class of leak actually originates.
The cost is stated under Provider logout is weaker than it looks.
Limits, stated plainly¶
Revocation lag is the same as LDAP's¶
For the same reason, too: login is the only moment sqi talks to the provider —
see Revocation lag. A user disabled or deleted at the
provider keeps their sqi session until it expires — auth.session.ttl, 7
days by default. They cannot get a new session; the one they hold keeps
working.
Shorten auth.session.ttl if that window matters. To cut someone off
immediately, disable the account in sqi (PATCH /api/v1/users/{id} with
disabled: true): the session authenticator re-checks the user record on every
request, and the local disabled flag overrides the provider for both session
checks and login.
Role changes apply at next login only¶
Under role_source: directory the role is recomputed from claims at login.
A user moved out of the admin group at the provider keeps admin in sqi for
the remaining life of their session. Disabling the account in sqi is again the
immediate lever.
Provider logout is weaker than it looks¶
logout_mode: provider on Keycloak is a confirmation prompt, not a silent
provider logout. Measured against Keycloak 26.0.7 by make test-oidc:
- The end-session request carrying
client_idandpost_logout_redirect_uriwith noid_token_hintis accepted — HTTP 200, both parameters honored, no error. - But the logout does not complete. Keycloak answers with an interactive
logout-confirmation page, and the provider session stays live behind it
(verified independently with a
prompt=noneprobe that still returned a code). The session ends only once a human posts that confirmation.
This is not a security hole — sqi's own session is genuinely revoked either
way, and it is revoked before the redirect is ever handed to the browser — but
it is weaker than an operator would infer from the option's name. In practice
logout_mode: provider ends the sqi session immediately and then takes the
user to the provider to confirm signing out everywhere. If someone abandons the
browser at that page, they are out of sqi and still signed in to every other
tool.
The confirmation page is a security control, not a Keycloak quirk. An
end-session request carrying only a client identifier could have been
constructed by anyone, so Keycloak asks the human before acting on it.
id_token_hint is the proof that the caller was party to the session, and
supplying it is exactly what buys the skip: with the hint, Keycloak answers
302 straight to the post-logout URI and the session is dead with no
interaction at all (client_id is not even required alongside it). sqi does
not hold that proof, by choice — see the reasons under
Re-authentication and logout are two different things.
The confirmation click is the price of not keeping a non-decaying,
exfiltratable logout capability at rest. It is a deliberate trade, not an
oversight.
Entra ID and Okta have not been measured. Whether either completes an
end-session request without an id_token_hint is unverified here; Okta has
historically been reported to require the hint, but that is recollection, not
an observation, and it is not a claim this document is willing to make. Test
your own provider before relying on logout_mode: provider, and treat
reauth_mode as the control that actually protects a shared workstation.
reauth_mode: after_logout is defeatable¶
The "this browser logged out" marker is an ordinary cookie. Clearing browser
storage removes it, and the next login is silent again. It is adequate against
the next colleague to sit down at a shared workstation; it is useless against
someone with developer tools open. reauth_mode: always is the hard
guarantee — it re-prompts every login regardless of what the browser is
carrying, at the cost of SSO's silent-login convenience.
A username collision blocks the second user's login¶
Two identities that map to the same username_claim value — or an SSO user
whose name is already taken by a local or LDAP account — cannot both exist.
The second one to log in is refused with the usual 401, and a WARN names
the collision in the server log. An admin renames one of the accounts to
resolve it.
Refusal is the intended outcome, not a gap. Auto-disambiguating (alice2)
would create accounts whose names no longer match the provider — and since the
username is what binds to Job.Owner, that is a mismatch that quietly spreads
into job ownership. Adopting the existing account is worse still: it is
username matching, the thing identifier matching exists to remove. An error an
operator can act on beats both.
Only the open-source providers are covered by CI¶
make test-oidc runs the whole SSO path against a real Keycloak in a
throwaway container, and make test-ldap runs the LDAP path against a real
OpenLDAP. Both run in CI on every change. That is the honest extent of it:
- Active Directory's
objectGUIDpath is not proven by the OpenLDAP container. OpenLDAP is exercised withentryUUID; AD's binaryobjectGUIDand its hex encoding are covered by unit tests against a fake, not by a live domain controller. - Okta and Entra ID are not proven by the Keycloak container. Nothing in CI contacts either. Claim shapes, group-claim delivery, and end-session behavior all differ between providers, and those are exactly the places this integration is sensitive.
Both are hosted or licensed products that cannot be provisioned in CI. Test a new deployment against your own provider before relying on it.
How this is tested¶
The unit tests drive a fake identity provider. It signs real tokens, so a
validation mistake surfaces — but a fake returns whatever the test asks for, so
it cannot show what a real provider omits. That is the gap
test/integration/oidc_test.go closes, driving a real browser-shaped flow
against a real Keycloak: the authorization-code round trip and group → role
mapping (including the silent default_role downgrade when the groups mapper
is missing), a rename at the provider keeping the same account, state-mismatch
refusal, prompt=login forcing re-authentication, and the end-session behavior
recorded above.
make test-oidc # needs Docker (or colima/podman); skips cleanly without it
See Testing against a real directory or identity
provider for the
SQI_TEST_OIDC_ISSUER escape hatch and why a skip verifies nothing.
Known gaps¶
- Broker authentication remains absent.
auth.enabledgates the HTTP REST API and the WebSocket upgrade only. It does nothing to the worker transport: any host that can reach the embedded NATS broker's port (4222by default) can register as a worker and receive task assignments, exactly as if auth were off. There is no plan to change this before Phase 4 — see the comment onbus.BrokerConfig.Addr(internal/bus/broker.go). An operator reading "I flippedauth.enabledtotrue, so the server is now locked down" should read that as "the HTTP/WebSocket surface is now locked down" — the worker-registration surface is unaffected either way. - Task isolation is implemented and integration-tested on both platforms.
See Task isolation above for the current state: a queue's
run_as_userruns job code as a distinct OS user on Linux/macOS workers, and as a distinct logon session on Windows workers viaLogonUserW. Both suites run against real OS accounts —make test-isolationas root in a container,make test-isolation-windowsagainst real local accounts with its privileged tier as SYSTEM — and both pass. The POSIX NSS (LDAP/AD-backed account) fallback path, however, has only ever been exercised against canned command output, never a real directory server — seedocs/worker-configuration.mdfor that caveat in full. - Windows staging has no TOCTOU re-check on stage-out, and isolation is what
makes it reachable. On POSIX,
internal/worker/staging'sbuiltinCopyre-checks for a symlink swap or a hardlink-count change (O_NOFOLLOW, hardlink-count) between its ownLstatand the elevated daemon's subsequent read of the same path. Windows has no equivalent re-check yet. Because a session directory is now genuinely ACL-secured to the target account, a task running under that account has write access to its own session directory and can in principle race a symlink/junction swap into the window betweenbuiltinCopy'sLstatand the daemon's later read during stage-out. An unisolated worker has nothing to gain from winning that race — its tasks already run as the daemon's own account — so isolation being enabled on Windows is precisely what makes this reachable. Not yet fixed; tracked for a follow-up before this is considered hardened. - Per-user concurrent task caps. A hard per-owner ceiling on running tasks was scoped for
Phase 3 and deferred (2026-07-20) with no driver behind it. Nothing bounds a single user's
farm consumption today:
max_concurrent_taskson farms and queues caps the container, not the person, so one user can still fill a queue's whole allowance. If that becomes a real problem, it is worth deciding between a hard cap and fair-share scheduling before building either.