115 lines
13 KiB
Markdown
115 lines
13 KiB
Markdown
## 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.
|
|
|
|
### Cookie sessions join the existing authentication chain
|
|
|
|
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.
|
|
|
|
### CSRF protection applies only to cookie-authenticated unsafe requests
|
|
|
|
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.
|