Files

13 KiB

Context

The deployable Cloud API currently builds one ChainedAuthProvider from configured public/Host bearer credentials plus repository-backed enrolled-Host credentials. A successful provider returns Principal(id, scopes, host_id) and every /v1 handler performs the same operation-specific scope check. The Cloud Console is therefore only a bearer-token holder: it stores an operator-pasted token in sessionStorage and has no user directory, password verification, login endpoint, session persistence, or user administration.

Cloud state already uses one SQLAlchemy repository implementation for SQLite and PostgreSQL and production startup requires Alembic to be current. The current schema ends at revision 0002_edge_host_enrollment; adding persistent users and sessions therefore requires a normal forward migration rather than application-managed table creation in production. The deployed Console is same-origin under /console/, while its Vite development mode can remain cross-origin through the existing exact-origin CORS allow-list.

This change follows the active cloud-console change without editing its files. That change's unarchived cloud-console-ui capability explicitly scoped out login/session/RBAC; this follow-up supersedes the default token-entry experience while preserving token entry as a compatibility path.

Goals / Non-Goals

Goals:

  • Give each human operator an attributable account and a familiar username/password login.
  • Reuse the existing Principal and scope enforcement instead of creating a second authorization model.
  • Keep browser credentials revocable, expiring, protected from JavaScript access, and safe for same-origin production deployment.
  • Let administrators manage user lifecycle and recover access without editing credential JSON or placing passwords in command arguments.
  • Preserve all current bearer-token, Host Agent, enrollment, and CloudClient integrations during migration and rollback.

Non-Goals:

  • Public registration, invitations by email, forgot-password email delivery, or identity proofing.
  • OIDC, OAuth login, SAML, LDAP, SCIM, MFA, WebAuthn, or social identity providers.
  • Tenants/organizations, per-resource ACLs, or user-defined roles and permissions.
  • Replacing Host Agent/enrollment credentials or removing configured public bearer tokens.
  • Making the Cloud API active-active; the existing single scheduler-enabled Cloud API topology remains unchanged.

Decisions

Persistent local users with fixed roles

Add cloud_users records with an opaque user id, original and normalized username, display name, Argon2id password hash, role, enabled state, must_change_password, authentication version, and created/updated/last-login timestamps. Usernames are trimmed and case-normalized for uniqueness, while the original spelling remains displayable.

Roles map to the existing scope vocabulary:

  • viewer: tasks:read, pool:read, plugins:read.
  • operator: viewer scopes plus tasks:submit.
  • admin: unrestricted *, including a new users:admin operation scope.

The fixed mapping keeps v1 authorization auditable and lets user principals flow through the same _authorize() checks as bearer principals. A customizable role/permission schema was rejected because the current API has only five resource scopes plus user administration; introducing role tables and policy editing now would add migration and lockout complexity without a demonstrated need.

Argon2id password hashing behind a small service boundary

Use a maintained Argon2id implementation through a PasswordHasher abstraction. Store only encoded hashes, use the library's constant-time verification and rehash signal, and rehash on successful login when parameters change. Passwords are accepted only by login/change/reset operations, are excluded from model representations and structured logs, and never cross into repository return models.

Fast hashes or reversible encryption were rejected because passwords require deliberately expensive, salted, one-way verification. Hand-rolling Argon2 parameters in route handlers was rejected so tests can inject a deterministic fake without weakening production defaults.

Opaque server-side sessions in secure cookies

Successful login creates at least 256 bits of random session material. Only its SHA-256 digest is stored in cloud_user_sessions; the plaintext value is sent as an HttpOnly, Secure, SameSite=Lax, Path=/ cookie. Session rows include user id, issued/last-seen/absolute-expiry timestamps, authentication version, revocation timestamp, and a digest for the CSRF token. Indexed digest lookup adds one bounded database query to cookie-authenticated requests.

Production always emits Secure cookies and therefore requires HTTPS at the browser-facing reverse proxy. Local/test configuration may explicitly use non-secure cookies. Session TTL and idle TTL are bounded configuration values; activity can extend the idle deadline but never the absolute deadline. Logout revokes the current row and clears cookies. Password reset, account disablement, role change, or authentication-version increment revokes all affected sessions immediately.

Self-contained JWTs were rejected because immediate account disablement, password-reset invalidation, role changes, and administrator session revocation would still require server-side state or short token lifetimes. A database-backed opaque session is simpler and matches the existing durable repository.

Add a repository-backed UserSessionAuthProvider that returns the same Principal type, with a distinguishable user principal id and scopes derived from the stored role. Compose it with the configured bearer provider; Host-bound authorization continues to require host_id, so a browser session cannot act as a Host Agent. Enrollment remains on its separate provider.

Bearer authentication remains valid on all existing routes. This is both the migration path and the non-browser automation contract. Maintaining two authorization implementations was rejected: all resource routes continue to consume only a Principal and required scope.

Versioned authentication and user-administration API

Add routes under /v1/auth for login, logout, current user, and password change, plus /v1/users routes for list/create/update, password reset, and session revocation. The login route is the only anonymous user endpoint. User administration requires users:admin; a configured bearer principal with * or users:admin can use it for automation/recovery as well as a logged-in administrator.

The first administrator is created with a separate device-cloud-admin console script. It connects to CLOUD_DATABASE_URL, checks the current schema, reads passwords interactively with confirmation via getpass, and supports create/reset/enable/session-revoke recovery operations. Passwords are never accepted as command-line flags. An environment bootstrap password was rejected because Compose interpolation, container inspection, deployment logs, and forgotten secret variables make one-time bootstrap credentials easy to retain accidentally.

The admin API prevents disabling or demoting the last enabled administrator. The recovery CLI remains available to deployment administrators if all browser sessions are lost.

Login creates a separate random CSRF value bound by digest to the session and exposes it to the same-origin Console through a non-HttpOnly cookie. For POST, PUT, PATCH, and DELETE requests authenticated by the session cookie, the API requires a matching X-CSRF-Token header. Bearer-authenticated requests are exempt because browsers do not attach those credentials automatically.

The Console sends credentials: "include", reads only the CSRF cookie, and adds the header on unsafe requests. Exact-origin CORS plus credentialed requests supports Vite development; wildcard origins remain forbidden. Depending only on SameSite was rejected because it is defense-in-depth rather than an explicit request-intent proof and can be weakened by future deployment/domain choices.

Bounded login throttling and generic failures

Persist short-lived failed-login counters keyed by normalized username and client-address bucket, with a bounded failure window and temporary block. Login returns the same response shape/status for unknown, disabled, blocked, and wrong-password accounts and still performs a dummy password verification for unknown users. Successful authentication clears the applicable counter. Expired throttle/session rows are deleted opportunistically in bounded batches.

Permanent account lockout was rejected because it creates a trivial denial-of-service path. Client address is taken from the direct peer unless an explicit trusted-proxy configuration permits forwarded addresses.

Security audit events are separate from operational logs

Persist bounded structured audit events for login success/failure category, logout, password change/reset, user create/update/disable, role change, and session revocation. Events contain timestamp, actor principal id, action, outcome, target user id, correlation id, and safe metadata; they never contain submitted passwords, password hashes, raw session/CSRF values, bearer tokens, or full request bodies. Operational logs may reference the audit event id.

Console supports account sessions and explicit token compatibility

The Console first calls /v1/auth/me. An authenticated user enters the dashboard; otherwise it shows username/password login with a secondary “Use API token” action. Session mode uses cookies and CSRF; compatibility mode retains the current tab-scoped bearer token. A 401 clears the active mode and returns to login, while a 403 remains an authorization error and does not destroy a valid session.

The shell adds current-user/logout/password-change controls. A Users navigation item is rendered only when the returned principal has users:admin, but backend scope checks remain authoritative. Users forced to change a temporary password are routed only to that flow until it succeeds.

Risks / Trade-offs

  • [Operators expose the Cloud API over plain HTTP] → Production cookies are always Secure; deployment documentation and readiness diagnostics state that browser login requires an HTTPS-facing origin.
  • [XSS can perform actions as the current user even without reading the HttpOnly cookie] → Keep CSP/static asset controls tight, escape rendered data, require CSRF headers, avoid dynamic HTML, and retain short/revocable sessions.
  • [Database lookup on every cookie-authenticated request] → Index the session digest and user id, return only the required user/session columns, and keep expiry cleanup bounded.
  • [Brute-force throttling can be abused to delay one username] → Use temporary username-plus-address buckets, generic responses, bounded windows, and admin recovery rather than permanent account locks.
  • [Static bearer tokens still bypass user lifecycle] → Keep them for compatibility but document least privilege and rotation; Console defaults to accounts and labels token mode as advanced/break-glass.
  • [The base cloud-console-ui spec is still in an active change] → Express this follow-up as additive delta requirements now; before archiving, reconcile the earlier token-only requirement so account login is primary and token entry is explicitly compatibility-only.
  • [Coarse fixed roles may not fit future teams] → Keep authorization expressed as scopes internally so a later custom-role or external-IdP change can supply a different scope set without changing resource handlers.

Migration Plan

  1. Add the Argon2 dependency, user/session domain models, repository port, SQLAlchemy implementation, and Alembic revision; verify upgrade/downgrade on SQLite and PostgreSQL.
  2. Add the password/session/authentication services, login throttling, audit recording, configuration validation, and UserSessionAuthProvider composition while leaving bearer behavior unchanged.
  3. Add /v1/auth/* and /v1/users/*, CloudClient parity, CLI administration commands, and backend contract/security tests.
  4. Update the Console for credentialed session requests, login/logout/password change, token fallback, and admin user management; run frontend type-check/build and browser-level flows.
  5. Deploy the migration and backend behind HTTPS. Existing bearer-token Console access continues to work.
  6. Run docker compose exec cloud-api device-cloud-admin users create --username <name> --role admin, enter the password interactively, verify account login, then rotate/reduce shared operator tokens as appropriate.
  7. Roll back application code first; the previous release continues to use configured bearer tokens and ignores the new tables. Preserve the tables for a forward fix, or back up the database and explicitly downgrade the migration only when user/session/audit history may be discarded.

Open Questions

  • OIDC/SAML and MFA are intentionally deferred. The Principal/scope boundary and local-user repository keep those future authentication providers additive.
  • Removing Console token entry is deferred until account login has operated successfully in production and a separate compatibility decision is made.