User accounts & authentication (E1)¶
The leader has a built-in user account system: register, login, browser
sessions, and per-user API tokens. Users live in the leader's state DB
(SWARM_JOIN_STATE_DB, default .swarm/join_state.sqlite3).
First run¶
The first registered user automatically becomes an active admin — no manual
DB setup needed. Every later registrant is a plain user whose starting status
depends on the flags below.
Configuration¶
| Env var | Default | Effect |
|---|---|---|
SWARM_REQUIRE_EMAIL_VERIFICATION |
off | New users start unverified and must confirm their email before login (requires the mailer, E11). |
SWARM_REQUIRE_ADMIN_APPROVAL |
off | New users start (or move to) pending_approval; an admin must approve them before they can log in. Works without SMTP. |
SWARM_SESSION_TTL_HOURS |
336 (14 days) |
Browser session lifetime. |
SWARM_IMPRINT_FILE |
(unset) | Path to an HTML fragment with the provider identification (§ 5 DDG). Unset serves a visible "not configured" placeholder. |
SWARM_PRIVACY_FILE |
(unset) | Path to an HTML fragment with the privacy notice (Art. 13 GDPR). Same fallback. |
SWARM_LEGAL_LANG |
en |
BCP 47 tag the two fragments are written in, so the pages declare their language (WCAG 3.1.1). |
SWARM_COOKIE_SECURE |
1 |
Session cookies are Secure (HTTPS-only). Set 0 only for plain-HTTP local development. |
SWARM_REQUIRE_NODE_AUTH |
on | Enforce node credentials on fellow → leader calls (E5). Set 0 to reopen the pre-E5 migration grace while nodes are upgraded. |
SWARM_REQUIRE_OPERATOR_AUTH |
on | Enforce the authorization sweep (E7): API routes require a session/token/admin per their class. Set 0 for a migration window. |
SWARM_REQUIRE_CSRF |
on | Double-submit CSRF check on cookie-authenticated state changes. |
SWARM_AUTH_RATE_MAX / SWARM_AUTH_RATE_WINDOW_S |
10 / 60 |
Auth-endpoint rate limit (attempts per window, per IP). |
SWARM_TRUSTED_PROXY_HOPS |
1 |
How many reverse proxies sit in front of the leader. The client IP is taken this many entries back from the right of X-Forwarded-For; 0 ignores the header entirely. |
SWARM_LOGIN_LOCKOUT_THRESHOLD / SWARM_LOGIN_LOCKOUT_SECONDS |
5 / 300 |
Failed logins from one (account, IP) pair before that pair is locked out. |
SWARM_ACCOUNT_LOCKOUT_THRESHOLD |
8 × the above |
Failed logins against one account from any address before the account itself locks. |
The three enforcement flags are on by default. They shipped default-off as
migration grace, which meant a fresh self-hosted deployment answered the whole
operator API anonymously and accepted fellow calls with no credential. The
leader logs a SECURITY: warning at startup for each one that is switched off.
Client IP and X-Forwarded-For¶
Rate limits and the audit log identify callers by IP, so where that IP comes
from is a security decision. X-Forwarded-For is append-only: the proxy chain
adds to the right, and the left-most entry is whatever the caller sent. The
leader therefore counts SWARM_TRUSTED_PROXY_HOPS entries back from the right
(see accounts/audit.py:client_ip) instead of trusting the left-most value -
otherwise one extra request header defeats every per-IP limit and writes a
forged address into the audit log. Set the value to the number of proxies you
actually run: 1 for the bundled nginx frontend, 2 with a tunnel in front of
it. It is independent of uvicorn's --forwarded-allow-ips
(SWARM_FORWARDED_ALLOW_IPS), which only affects the scheme/host in
request.base_url.
Account states: unverified → pending_approval → active → disabled.
Login is only possible in active; the login error explains why otherwise.
Pages¶
| Route | Access | Purpose |
|---|---|---|
/ |
public | Landing page (register/login CTAs); logged-in users are redirected to /dashboard. |
/de |
public | The same landing page in German; /en is a 301 to /, which stays the English canonical. |
/robots.txt, /sitemap.xml, /llms.txt |
public | Crawler-facing files. The sitemap lists both languages with their hreflang alternates. |
/login, /register |
public | Auth forms (redirect to /dashboard when already logged in). |
/dashboard |
session | Post-login home: live pools (with plan quota), create-pool flow, tiles for Training Console, Inference, Profile. |
/pools/{id} |
session + pool visibility | Pool detail: live node status, per-pool join command, jobs, models, members; edit/delete for the owner. Links to the pool-scoped Training Console (/ops/join-v2?pool={id}). |
/profile |
session | Account details, password change, personal API tokens. |
/legal/imprint, /legal/privacy |
public | Legal pages; content from SWARM_IMPRINT_FILE / SWARM_PRIVACY_FILE (HTML fragments), placeholder otherwise. Set SWARM_LEGAL_LANG to the BCP 47 tag those fragments are written in (de for a German Impressum) - the page declares it on <html lang>, and an unparseable tag falls back to en. |
/legal/accessibility |
public | Erklärung zur Barrierefreiheit (BITV 2.0 § 12b). Rendered in the visitor's interface language. |
/legal/leichte-sprache |
public | The BITV 2.0 § 3 (2) explanation in Leichte Sprache. Always German. |
/lang/{code} |
public | Remembers an interface language in the swarm_lang cookie and returns to the referring page (same-origin paths only). |
The ops pages (/ops/join-v2, /ops/inference) are session-gated: without a
session they redirect to /login (303). With one they show the logged-in chip -
name, Dashboard link, logout.
Endpoints¶
| Route | Method | Purpose |
|---|---|---|
/api/v1/auth/register |
POST | {email, password, display_name?} — creates the account; logs it straight in when it starts active. |
/api/v1/auth/login |
POST | {email, password} — sets the session cookie. 401 bad credentials, 403 not-yet-active (with reason). |
/api/v1/auth/logout |
POST | Revokes the session, clears the cookie. |
/api/v1/auth/me |
GET | The logged-in user. |
/api/v1/auth/password |
POST | {current_password, new_password} — changes the password and revokes all other sessions. |
Passwords are hashed with scrypt (per-user salt); session and API tokens are
stored only as SHA-256 hashes. State-changing endpoints accept JSON bodies only,
which together with SameSite=Lax cookies blocks cross-site form POSTs.
API tokens (/api/v1/api-tokens) minted while logged in belong to that user;
plain users see and revoke only their own tokens, admins see all.
Pools (admin management, E3)¶
Pools are the spaces where nodes land and connect (e.g. a public pool
thuringia for a localized deployment). Admins manage them without
restrictions at /admin/pools (admin-only page; plain users are redirected
to the dashboard):
- Create with name, slug, description, visibility (
public= listed to all users,private= members only) and an optional node limit (empty = unlimited). - Edit name/description/visibility/limit; the slug is fixed at creation.
- Delete refuses while nodes are attached unless forced (force detaches them) — no silent orphaning.
API (admin session required): GET/POST /api/v1/admin/pools,
GET/PATCH/DELETE /api/v1/admin/pools/{id} (DELETE ?force=true to detach
nodes). The pool owner is automatically its first member. Node↔pool binding is
enforced from E5 on.
User pools & quotas (E4)¶
Any logged-in user can create pools through /api/v1/pools (same verbs as
the admin surface plus GET /api/v1/pools/limits), enforced against the
role_limits table in the state DB:
| Role | Max pools | Max nodes/pool | Visibilities |
|---|---|---|---|
user (default seed) |
1 | 2 | private only |
admin |
unlimited | unlimited | public + private |
Quotas are data, not code — edit the role_limits rows to change a
deployment's plans; enforcement lives in the pool service layer and error
messages state the plan ("Your plan allows 1 pool(s) with up to 2 node(s)
each."). A user pool snapshots the plan's node limit at creation; private
pools are visible only to their members (and admins), public pools are listed
to every logged-in user. GET /api/v1/pools annotates each pool with
my_role (owner / member / null).
Pool-scoped nodes & node credentials (E5)¶
Nodes belong to pools, and everything they do is scoped to their pool:
- Join tokens are pool-scoped.
POST /api/v1/bootstrap/join-sessionsaccepts apool_id; the generated "Add Fellow" command carries that pool's token. A fellow registering with it lands in the pool (pool_nodes), subject to the pool's node limit (409 when full). No token → the deployment default pool (created lazily; migration target for pre-pool fellows). - Node credentials. Registration returns a one-time
node_token(stored hashed; re-registration re-mints it). The fellow presents it as a Bearer token on heartbeat, relay push/pop, log shipping, artifact/servable uploads, job status posts, the signal WebSocket and the inference transport — including the torch subprocesses (training relay via the transport spec, inference relay via theSWARM_RELAY_TOKENenv). - Enforcement:
SWARM_REQUIRE_NODE_AUTHis on by default and rejects calls without a valid credential.SWARM_REQUIRE_NODE_AUTH=0reopens the pre-E5 migration grace, where missing tokens pass with a debug log — but a wrong/revoked/foreign token is always rejected, in either mode. - The inference transport is scoped to the node that owns the request. A
fellow long-polls
GET /api/v1/infer/requests/{fellow_id}for work and posts frames back toPOST /api/v1/infer/responses/{request_id}. Both check the credential, and the push side checks it against the fellow the request was dispatched to (InferenceBroker.owner_of) rather than accepting any registered node: otherwise one node could write tokens into another node's generation, and the client streaming that answer would have no way to tell. A request id with no live owner answers410 Gone, which is the fellow's signal to abort the generation. - The signal WebSocket checks the credential in its handshake. ASGI runs no
HTTP middleware for a websocket scope, so
/ws/signals/{fellow_id}cannot lean on the authorization sweep below and calls the same check itself (leader/cluster/node_auth.py), refusing before accepting the socket. The token must belong to thefellow_idin the path: a valid credential is not a cluster-wide key, so one node can never claim another's control channel — a connection that could otherwise evict the real node and receive its PURGE/PAUSE signals. A refused handshake answers the upgrade with HTTP 403 (close code4401where the transport surfaces it).python -m silent_swarm.fellow.health_checkholds no credential, so once enforcement is on it treats that 401/403 as a healthy leader: the refusal still proves the leader is up and the route is serving. - Scheduling isolation. A job is bound to a pool
(
JobSubmitRequest.pool_id, default pool otherwise) and only ever combines that pool's nodes; logged-in non-admins can only schedule into pools they belong to and only see their pools' jobs. - Catalog scoping. Servable manifests are stamped with the training job's pool; logged-in non-admins see only their pools' models (plus pre-E5 unstamped ones), and a pool's model is only ever served on that pool's nodes.
Authorization sweep (E7)¶
Every leader route has an entry in
route_policy.py — the single
source of truth — classifying it as public, page (self-gating HTML),
node (fellow credential), chat (API token), session, operator
(session or API token — UI and CLI), or admin. A route with no entry is
unknown = default-deny: the middleware locks it to admin and
test_route_policy.py fails, so an unauthenticated route can't ship by
accident.
Enforcement of session/operator/admin on /api/* routes activates with
SWARM_REQUIRE_OPERATOR_AUTH=1 (grace-off by default, mirroring node auth).
HTML pages keep their own in-handler redirects. Auth events (login, register,
failures) and admin actions (worker release) are written to an audit log,
readable by admins at GET /api/v1/admin/audit. Login/register are rate-limited
per IP.
Both are on by default now; a deployment mid-migration sets them to 0
temporarily, in this order: node auth back on once fellows have re-registered,
then operator auth once operators use sessions or API tokens (the CLI already
sends its token).
Scope: what "operator" actually reaches¶
operator is an access class, not a blank cheque. A session and an API token
resolve to the same person (accounts/auth.py:principal_user), and both are
held to that person's pool membership - minting a token is not a way around
your own scope:
- jobs, models, nodes and their logs/metrics are filtered to the caller's pools; a cross-pool id answers 404, not 403, because the existence of another tenant's resource is itself information;
DELETE /api/v1/jobs(purge everything, cluster-wide) is admin-only and audited - it ignores pool boundaries by definition, so it cannot be scoped;- platform admins are unscoped, as before.
Per-node telemetry (/api/v1/pools/{id}/metrics, …/events, …/metrics/history)
requires membership, not just visibility of the pool: a public pool is
listed to every logged-in user, but its machines' hostnames, GPUs and health are
not.
Contribution accounting (landing E7, rung 1)¶
A node is attributed to the member whose join token admitted it:
POST /api/v1/bootstrap/join-sessions records the minting session's user, and
registration copies that onto the node's pool binding
(pool_nodes.owner_user_id). The owner is never read from a request body, so one
member cannot claim another's machine.
The metrics roll-up loop then accumulates, per member: GPU-seconds their online
machines contributed, and the tokens and requests those machines served. Each
tick reads serving over exactly the tick's window, so consecutive ticks abut
rather than overlap. GET /api/v1/me/contribution returns the caller's own
totals - there is no parameter for asking about anyone else - and reports
credits_status: "not_implemented", because this rung is accounting, not
money. Nodes that joined without a member's token stay unattributed rather
than being credited to a guess.
The one public telemetry route¶
GET /api/v1/public/stats feeds the landing page's numbers and is deliberately
anonymous, which makes it the exception that has to justify itself:
- aggregate only - counts and rounded totals, never a per-node, per-pool or per-model row, so it cannot become a back door to the member-only snapshot above;
- withheld below a floor (
SWARM_PUBLIC_STATS_MIN_NODES, default 3): with one or two machines the numbers describe an identifiable person's computer, so they are published asnulland the page says the pool is just getting started; - cached (
SWARM_PUBLIC_STATS_TTL_S, default 60s) and rounded, because an exact live counter is a liveness oracle: poll it and you learn when somebody's PC is switched on; - rate-limited at the edge in the bundled nginx config.
GPU-hours contributed is a real measured total, integrated by the metrics roll-up loop from observed uptime, not an estimate.
Pool collaboration (E8)¶
Public pools can be shared; private pools are invite-only:
- Invites — a pool owner (or admin) invites by email:
POST /api/v1/pools/{id}/invites {email}. The invitee sees pending invites atGET /api/v1/pools/invites/mineand accepts withPOST /api/v1/pools/invites/{invite_id}/accept(the email must match). - Join requests — for a public pool,
POST /api/v1/pools/{id}/joineither joins immediately (when the pool has open-join enabled) or files a request the owner approves atPOST /api/v1/pools/{id}/join-requests/{user_id}/approve. - Roles & leaving — members have the
memberrole (owner keepsowner); a member can leave and an owner/admin can remove others viaDELETE /api/v1/pools/{id}/members/{user_id}. The owner can't be removed — delete the pool instead. Invite mail is sent through the E11 mailer when configured (best-effort until then).
Admin user management (E10)¶
Admins manage every account at /admin/users (admin-only page). The table is
server-side sorted / filtered / searched / paginated via
GET /api/v1/admin/users?q=&status=&role=&sort=&order=&page=&page_size=, with
columns for status, role, pool/node counts, registration and last-login. A
pending-approval badge appears on the dashboard tile
(GET /api/v1/admin/users/pending-count).
Row actions (all audit-logged):
- Approve — the manual registration confirmation:
pending_approval/unverified→active. - Edit —
PATCH …/{id}display name, email, role — with last-admin protection (can't demote/deactivate/delete the last active admin). - Deactivate / reactivate —
POST …/{id}/status; deactivation revokes the user's sessions and API tokens. - Reset password —
POST …/{id}/reset-passwordissues a single-use, 24h-expiring reset token. With SMTP it is mailed; without, the response carries a one-time/reset-password?token=…link the admin hands over. - Resend verification — re-issues the email-verification token.
- Delete — the E9 GDPR cascade (below).
Account deletion & hardening (E9)¶
GDPR deletion. Deleting a user removes everything that grants access, in a guarded order (last-admin refused before anything is touched): their owned pools are deleted (nodes detached), their memberships in other pools dropped, their API tokens revoked, then the user row, sessions and action tokens are removed. The response summarizes what was deleted.
Brute-force lockout. After SWARM_LOGIN_LOCKOUT_THRESHOLD (default 5)
failed logins for an email, that account is locked for
SWARM_LOGIN_LOCKOUT_SECONDS (default 300s); a success clears the counter. This
is per-account and complements the per-IP auth rate limit.
Session hygiene. Sessions are hash-stored and TTL-bounded
(SWARM_SESSION_TTL_HOURS); UserStore.purge_expired_sessions() drops
revoked/expired rows. Action tokens (verify/reset) are single-use and expire in
24h.
Backup & restore. All account, pool, job, and audit state lives in the one
SQLite file (SWARM_JOIN_STATE_DB, default /data/join_state.sqlite3 in Docker
— a bind-mounted volume). To back up, stop the leader (or use
sqlite3 <db> ".backup '<dest>'" for a hot copy) and archive the file; to
restore, put the file back before starting the leader. Users, pools and node
bindings all survive the round-trip. Keep SMTP credentials and the mesh
pre-auth key in .env (see .env.example), never in the image or the repo -
.dockerignore excludes .env and .swarm/ so a build cannot bake them in.
Transactional email (E11)¶
When SMTP is configured the leader sends lifecycle mail; without it the mailer is disabled and every feature degrades gracefully (verification skipped, approval via the admin UI, password reset falls back to a one-time link).
Configure via env (all optional): SWARM_SMTP_HOST, SWARM_SMTP_PORT,
SWARM_SMTP_USER, SWARM_SMTP_PASSWORD, SWARM_SMTP_FROM, SWARM_SMTP_TLS
(starttls | ssl | none).
- Queued, non-blocking delivery. Mails are written to a persistent outbox in the state DB and delivered by a background worker with retry/backoff (up to 5 attempts), so a slow or down SMTP server never blocks the triggering request.
- Templates: verify-email, pending-approval, account-approved, account-deactivated, password-reset, password-changed, and pool-invite — tokenized links are single-use and expire in 24h.
- Admin surface: the
/admin/userspage shows an Email card (configured / disabled, pending count) with a Send test email button (GET /api/v1/admin/mail,POST /api/v1/admin/mail/test). From the leader host,swarm admin send-test-mail you@example.comvalidates SMTP directly against the host's env.
Locked out? CLI escape hatch¶
On the leader host (no HTTP, operates directly on the state DB):
# Create an active admin account
swarm admin create-user you@example.com # prompts for password
# Reset a password (also revokes that user's sessions)
swarm admin set-password you@example.com
Both accept --db /path/to/join_state.sqlite3 when the DB isn't at the default
location (in Docker: run inside the leader container, DB at /data/join_state.sqlite3).
Accessibility¶
The UI targets EN 301 549 (= WCAG 2.1 AA), which is the technical standard both
BITV 2.0 and the BFSG point at. tests/unit/leader/web/test_accessibility.py
holds the structural invariants and scripts/frontend_smoke.mjs runs axe-core
plus a 320 px reflow check over every page in CI.
Three obligations are organisational rather than technical, and are deployment-specific because an operator who changes the UI changes the answer:
| Setting | What it does |
|---|---|
SWARM_A11Y_CONTACT |
Where accessibility barriers get reported. Shown on the statement and the Leichte Sprache page; without it, both say plainly that no contact is configured. An email address becomes a mailto: link, anything else a plain link. |
SWARM_ACCESSIBILITY_FILE |
An HTML fragment replacing the built-in statement body. Use this once you have modified the UI: the built-in text describes this codebase as audited, and a statement that no longer matches the site is worse than none. |
SWARM_DGS_VIDEO_URL |
A video explaining the site in Deutsche Gebärdensprache. When set, it is embedded on both pages; when unset, they say that it is missing rather than dropping the section. |
The statement claims partial conformance and lists the known gaps. Keep it
honest and keep STATEMENT_REVIEWED in leader/web/accessibility.py current -
BITV expects the statement reviewed whenever the site changes materially.
Interface language¶
The chrome (sidebar, drawer, account menu) is available in English and German; page bodies are English. The language is chosen in this order:
- the
swarm_langcookie, set by/lang/{code}or by visiting/or/de; - the request's
Accept-Languageheader; - English.
When the chrome ends up in a different language from the page body, its roots
are marked with their own lang so a screen reader pronounces both correctly
(WCAG 3.1.2). Add a language by extending CHROME_DE in leader/web/i18n.py
and adding its code to LANGS.
The legal pages¶
/legal/imprint and /legal/privacy render an HTML fragment you supply -
the app provides the page, the heading and the chrome. Until you configure them
both serve a visible "this deployment has not configured …" placeholder, which
is honest but is not a privacy notice.
For a public deployment in Germany neither is optional: a missing Impressum is enforceable by competitors, and Art. 13 GDPR applies from the first page view, because the server log already processes an IP address.
Start from the templates in docker/legal/:
mkdir -p data/docker/leader/legal
cp docker/legal/imprint.html.example data/docker/leader/legal/imprint.html
cp docker/legal/privacy.html.example data/docker/leader/legal/privacy.html
$EDITOR data/docker/leader/legal/*.html # fill every «...» marker
SWARM_IMPRINT_FILE=/data/legal/imprint.html
SWARM_PRIVACY_FILE=/data/legal/privacy.html
SWARM_LEGAL_LANG=de
./data/docker/leader is mounted at /data, so the paths above are the
container's view of those files. They live on the volume rather than in the
image on purpose: they are your company's text, not the software's, and a
rebuild must not be able to replace them.
The privacy template's factual sections - cookies, the aggregate view counter,
what an account and the audit log store - were written against the code and are
accurate for an unmodified deployment; the
public web surface page carries the same inventory.
What only you can supply is who you are, who processes data on your behalf, and
how long you keep it. Those are marked «...» and render highlighted, so a
half-filled fragment is obvious on the page instead of shipping quietly.
None of this is legal advice - it is an accurate description of what the software does, which is the part a lawyer would otherwise have to reverse engineer.