docs(cloud-console): document user authentication

This commit is contained in:
2026-07-13 17:55:21 +08:00
parent cdef630e67
commit c72c31de04
10 changed files with 595 additions and 113 deletions
+33 -71
View File
@@ -1,95 +1,57 @@
# Cloud Console
Independent Vue 3 + Vite single-page app for the Cloud Control Plane
(`apps/cloud-api`). Operators authenticate by pasting a pre-issued scoped
bearer token; the console stores it in `sessionStorage`, attaches
`Authorization: Bearer <token>` to every request, and clears it whenever the
Cloud API responds `401` or `403`.
Vue 3 + Vite single-page app for the Cloud Control Plane (`apps/cloud-api`).
The primary flow is a Cloud user account: username/password login creates an
expiring, revocable `HttpOnly` session cookie, while the frontend sends the
separate CSRF cookie value on writes. The browser never stores the session
secret in JavaScript.
The app talks only to the platform SDK surface (`/v1/...`) and consumes the
two listing endpoints added by the `cloud-console` change (`GET /v1/tasks`,
`GET /v1/tasks/{task_id}/attempts`) alongside the existing
`/v1/devices`, `/v1/hosts`, `/v1/plugins`, and `POST /v1/plugins` routes.
The login screen also offers **Use API token** for existing break-glass or
automation credentials. That token is held only in `sessionStorage`; it remains
compatible with the existing scoped `CLOUD_PUBLIC_CREDENTIALS_JSON` model.
## Prerequisites
- Node.js 20+ (matching the existing `console/` SPA project)
- A running Cloud API (`apps/cloud-api`) reachable from your browser
- A bearer token issued via `CLOUD_PUBLIC_CREDENTIALS_JSON` whose scopes cover
what you intend to do from the console. Recommended least-privilege set:
- `tasks:read` — task list and attempt history views
- `pool:read` — device and host views
- `plugins:read` — plugin list
- Add `tasks:submit`/`plugins:admin` only if you need the write actions from
the same tab.
- Node.js 20+
- A current Cloud API database migration and at least one administrator created
with `device-cloud-admin users create ...`
- HTTPS for production: `CLOUD_SESSION_COOKIE_SECURE=true` is required in a
production Cloud API. Terminate TLS at the origin serving `/console/`.
## Configure the backend CORS allow-list
Accounts have fixed roles:
The Cloud API has no CORS middleware by default. Before a browser can call it
cross-origin, set `CLOUD_CONSOLE_CORS_ORIGINS` to a comma-separated allow-list
that includes the exact origin your dev server prints (scheme + host + port —
no trailing slash):
- `viewer`: task, device/host, and plugin read views
- `operator`: viewer access plus task submission APIs
- `admin`: all API scopes and the Console Users view
```bash
# Example: allow the default Vite dev origin
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
```
Restart `apps/cloud-api` after changing this env. Tokens are still required —
the allow-list only says which browser origins may send them.
## Run the dev server
## Local development
```bash
cd cloud-console
cp .env.example .env.local
# Edit .env.local if your Cloud API is not at http://127.0.0.1:8001
# Point this to the local Cloud API when it is not http://127.0.0.1:8001
npm install
npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). Open it, paste a
bearer token, and the task/device/host/plugin dashboards become available.
`.env.local` overrides the default base URL via `VITE_CLOUD_API_BASE_URL`
(defaults to `http://127.0.0.1:8001`).
## Build for production
For a Vite origin such as `http://127.0.0.1:5173`, configure the API with the
exact origin and disable secure cookies only in local/test mode:
```bash
npm run build # type-checks with vue-tsc, then emits dist/
npm run preview # serves the built bundle locally
export CLOUD_CONSOLE_CORS_ORIGINS="http://127.0.0.1:5173"
export CLOUD_SESSION_COOKIE_SECURE=false
```
`dist/` is a static bundle — host it behind any static file server or CDN and
point it at a deployed Cloud API via `VITE_CLOUD_API_BASE_URL` set at build
time.
The Console uses `credentials: include`. `401` returns to the login screen;
`403` remains an authorization error so an otherwise valid session is retained.
## Token handling
## Production
- The token is held in `sessionStorage` only. Closing the tab discards it.
- Every API request attaches `Authorization: Bearer <token>` and targets only
the configured `VITE_CLOUD_API_BASE_URL`.
- A `401`/`403` response clears the stored token and returns the operator to
the token-entry screen with the API's error detail.
`npm run build` type-checks and creates `dist/`. The repository Dockerfile
already builds this bundle into `/app/console-static`; `compose.yaml` and
`compose.deploy.yaml` mount it at the same-origin `/console/` route. No CORS
configuration is required in that deployment shape.
## Project layout
```
cloud-console/
├── src/
│ ├── api.ts # API client wrapper (token storage, fetch, errors)
│ ├── types.ts # TS interfaces mirroring the REST models
│ ├── App.vue # Shell: token gate, nav, view router
│ ├── main.ts # Vue bootstrap
│ ├── style.css # Dark theme styles
│ └── views/
│ ├── TokenScreen.vue
│ ├── TasksView.vue # list + detail with attempt history
│ ├── DevicesView.vue # device pool + host registry
│ └── PluginsView.vue # registry list + registration form
├── index.html
├── package.json
├── tsconfig.json / tsconfig.node.json
└── vite.config.ts
```
Administrators can create users, assign roles, enable/disable accounts, reset
temporary passwords, and revoke sessions. All password inputs are cleared from
the UI after a create/reset request succeeds or fails.
+64 -33
View File
@@ -186,33 +186,62 @@ PY
## Cloud Console (Web UI)
The repository ships an independent Vue 3 + Vite SPA at `cloud-console/` that
renders the task queue/history, device pool, host registry, and plugin
registry, and exposes the existing plugin-registration action. It authenticates
the same way `CloudClient` does: by attaching a pre-issued bearer token to
every request. There is no login or session system.
renders task history, devices, hosts, plugins, and the user directory. Human
operators sign in with a username and password; the Cloud API creates an
expiring, revocable `HttpOnly` session cookie and uses a separate CSRF
cookie/header for writes. Existing bearer tokens remain available through the
Console's explicit **Use API token** action and for SDK, Host Agent, and
automation compatibility.
### Provision an operator bearer token
### HTTPS and session configuration
Add a `CLOUD_PUBLIC_CREDENTIALS_JSON` entry whose scopes cover what the
console operators need to do. The least-privilege set for read-only dashboards
is `tasks:read`, `pool:read`, and `plugins:read`. Add `tasks:submit` only if
operators should submit ad-hoc tasks from the same tab, and `plugins:admin`
only if operators should register plugins:
`CLOUD_ENVIRONMENT=production` requires `CLOUD_SESSION_COOKIE_SECURE=true`.
Terminate TLS at a reverse proxy and open the same-origin Console through HTTPS,
for example `https://cloud.example.com/console/`. Direct `http://host:8001`
access is for local/test mode only; it cannot retain production login cookies.
```json
[
{
"principal_id": "console-operator",
"token": "replace-with-a-long-random-opaque-token",
"scopes": ["tasks:read", "pool:read", "plugins:read", "plugins:admin"]
}
]
The defaults are an 8-hour idle session TTL, 7-day absolute TTL, and a temporary
block after five failed logins in a 15-minute username/client-address window:
```text
CLOUD_USER_SESSION_IDLE_SECONDS=28800
CLOUD_USER_SESSION_ABSOLUTE_SECONDS=604800
CLOUD_LOGIN_FAILURE_LIMIT=5
CLOUD_LOGIN_FAILURE_WINDOW_SECONDS=900
CLOUD_LOGIN_BLOCK_SECONDS=900
CLOUD_SESSION_COOKIE_SECURE=true
CLOUD_TRUST_PROXY_HEADERS=false
```
Rotate the token the same way as any other credential entry: deploy the
updated Cloud API credential set and instruct operators to paste the new token
into the console. The console keeps the token only in browser `sessionStorage`
for that tab; closing the tab discards it.
Set `CLOUD_TRUST_PROXY_HEADERS=true` only when a trusted proxy overwrites
`X-Forwarded-For` before requests reach the Cloud API.
### Create and recover administrator accounts
After migrations and Cloud API startup, create the first account interactively:
```bash
docker compose exec cloud-api \
device-cloud-admin users create \
--username admin --display-name "Cloud Administrator" --role admin
```
The command prompts twice for the password, so it does not enter shell history,
Compose configuration, process arguments, logs, or container inspection output.
Recovery commands are also interactive:
```bash
docker compose exec cloud-api device-cloud-admin users reset-password --username admin
docker compose exec cloud-api device-cloud-admin users enable --username admin
docker compose exec cloud-api device-cloud-admin users revoke-sessions --username admin
```
Roles are fixed: `viewer` can read tasks/pool/plugins; `operator` additionally
submits tasks; `admin` has unrestricted Cloud API access and manages users.
Administrators create users, reset passwords, change roles, disable accounts,
and revoke sessions from the **Users** Console view. New and reset users must
change their temporary password before accessing other resources, and the API
will not disable or demote the last enabled administrator.
### Configure the CORS allow-list
@@ -231,7 +260,7 @@ export CLOUD_CONSOLE_CORS_ORIGINS="https://console.example.com"
Restart the Cloud API after changing this env. The middleware is added only
when the allow-list is non-empty — existing deployments see no behavior change
until an operator opts in. Blanket `allow_origins=["*"]` is intentionally not
supported because every console request carries a bearer token.
supported because browser sessions are credentialed.
### Run the console
@@ -244,8 +273,10 @@ npm run dev
```
Vite prints a local URL (default `http://127.0.0.1:5173`). That exact origin
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. Open the dev URL,
paste the operator token, and the dashboards become available.
must be in `CLOUD_CONSOLE_CORS_ORIGINS` on the Cloud API. For local development
set `CLOUD_SESSION_COOKIE_SECURE=false`, then open the dev URL and sign in with
a user account. The Console sends credentialed requests and attaches CSRF proof
to writes.
For a production build, run `npm run build` and serve the resulting `dist/`
behind any static file server or CDN, with `VITE_CLOUD_API_BASE_URL` baked in
@@ -257,15 +288,15 @@ The Jenkins-built Docker image already carries the SPA at `/app/console-static`,
and `compose.yaml` / `compose.deploy.yaml` set
`CLOUD_CONSOLE_STATIC_DIR=/app/console-static` on the `cloud-api` service. In
this mode the Cloud API itself serves the console at `/console/` (visiting `/`
307-redirects there), so operators can open `https://<cloud-api-host>:8001/`
directly — no separate dev server, no static host, no CORS allow-list needed
(the SPA and the API share one origin).
307-redirects there). Put that origin behind an HTTPS reverse proxy, then open
for example `https://cloud.example.com/` directly — no separate dev server, no
static host, and no CORS allow-list are needed because the SPA and API share one
origin.
The SPA shell (`index.html`, JS, CSS) is served without a bearer token by
design — `_authorize(...)` is called inside the `/v1/*` route handlers, not in
middleware, so the SPA can boot before the operator pastes a token. All
`/v1/*` API calls still require `tasks:read`/`pool:read`/`plugins:read` scopes
as before.
The SPA shell (`index.html`, JS, CSS) is served without credentials by design
so it can render the login page. All `/v1/*` resource calls remain scope-gated,
and unsafe cookie-authenticated calls require CSRF proof. The browser receives
only the non-secret CSRF value; it never receives the `HttpOnly` session secret.
To opt out (e.g. for local development where you run `npm run dev`), leave
`CLOUD_CONSOLE_STATIC_DIR` unset. The mount is conditional on that env var.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,114 @@
## 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.
@@ -0,0 +1,34 @@
## Why
The Cloud Console currently asks every operator to paste a long-lived, pre-shared bearer token. That is awkward for routine access and provides no per-user password lifecycle, role assignment, session revocation, or reliable attribution when several people operate the control plane.
## What Changes
- Add a persistent Cloud user directory with unique usernames, display names, password hashes, active/disabled state, fixed roles (`viewer`, `operator`, and `admin`), and security timestamps.
- Add username/password sign-in, sign-out, current-user, and password-change endpoints backed by revocable, expiring server-side sessions delivered through secure `HttpOnly` cookies.
- Map user roles to the Cloud API's existing operation scopes so browser users and configured bearer-token principals pass through the same authorization checks.
- Add admin-only user management endpoints and Console views for listing users, creating accounts, changing roles/status, resetting passwords, and revoking sessions.
- Add an interactive administration CLI for securely creating the first administrator and recovering access without embedding a bootstrap password in an image, Compose file, or shell argument.
- Replace the Console's default token-entry gate with a username/password login screen and authenticated account menu; retain an explicit bearer-token option for existing operators and break-glass access.
- Add login throttling, generic authentication failures, password/session invalidation rules, CSRF protection for cookie-authenticated writes, and audit events that never record passwords, session secrets, or bearer tokens.
- Preserve configured bearer-token authentication for `CloudClient`, Host Agents, enrollment, automation, and backward compatibility.
- Explicitly out of scope: public self-registration, email delivery or forgot-password links, OIDC/SAML/LDAP, multi-factor authentication, organization/multi-tenant membership, and customizable role definitions.
## Capabilities
### New Capabilities
- `cloud-user-authentication`: Persistent users, password verification, role-to-scope authorization, secure session lifecycle, administrator account management, bootstrap/recovery tooling, throttling, and security audit behavior.
### Modified Capabilities
- `platform-sdk`: Add versioned user-authentication and user-administration routes, and allow a valid browser session principal alongside the existing bearer-token principals without weakening operation-specific scope checks.
- `cloud-console-ui`: Make username/password login the primary operator flow, add account/session handling and an admin-only user-management view, while keeping the existing token flow as an explicit compatibility option. The base capability remains in the active `cloud-console` change, so this follow-up supplies additive delta requirements until that change is archived.
## Impact
- **Database**: a new Alembic revision adds user, session, login-attempt/audit state with equivalent SQLite and PostgreSQL behavior; rollback invalidates browser sessions and removes user records only when the explicit downgrade is run.
- **Backend**: `cloud/auth.py`, repository/database models, SQLAlchemy repository code, Cloud API composition, `/v1/auth/*` and `/v1/users/*` models/routes, and production configuration gain user-session support while retaining current bearer providers.
- **Frontend**: `cloud-console/` replaces its default token screen with account login, sends credentialed requests plus CSRF protection, exposes logout/password change, and adds admin user management.
- **Dependencies**: add a maintained Argon2id password-hashing dependency; continue storing only digests for bearer/session credentials.
- **Operations/docs**: document HTTPS and secure-cookie requirements, initial-admin creation via `docker compose exec`, role semantics, user recovery, session revocation, and migration/rollback procedures.
@@ -0,0 +1,58 @@
## ADDED Requirements
### Requirement: Account login is the primary Console authentication flow
The Console SHALL use username/password login and a server-managed user session as its primary authentication flow, while retaining the existing tab-scoped bearer-token flow behind an explicit compatibility action.
#### Scenario: Operator opens the Console without authentication
- **WHEN** `/v1/auth/me` reports no valid user session and no compatibility bearer token is active
- **THEN** the Console displays the username/password login form as the primary action and offers a secondary “Use API token” action
#### Scenario: Login succeeds
- **WHEN** an operator submits valid account credentials
- **THEN** the Console enters session mode, displays the authenticated user's identity, and loads only views allowed by the returned effective scopes
#### Scenario: Login fails
- **WHEN** the login endpoint rejects credentials or throttles the attempt
- **THEN** the Console shows the generic authentication failure without revealing whether the username exists or persisting the password
#### Scenario: Operator chooses token compatibility
- **WHEN** an operator explicitly selects “Use API token” and enters a bearer token
- **THEN** the Console retains that token only in tab-scoped session storage and uses the existing bearer request behavior
### Requirement: Console manages authenticated session lifecycle
The Console SHALL send cookies on user-session requests, attach session CSRF proof to unsafe requests, provide logout and password-change controls, and distinguish authentication loss from insufficient authorization.
#### Scenario: User logs out
- **WHEN** a logged-in user activates logout
- **THEN** the Console submits CSRF-protected logout, clears local authentication state, and returns to the login screen
#### Scenario: Session expires
- **WHEN** a session-authenticated request returns `401`
- **THEN** the Console clears session UI state and returns to login with a session-expired message
#### Scenario: User lacks a required scope
- **WHEN** an otherwise valid session-authenticated request returns `403`
- **THEN** the Console preserves the session and shows an authorization error for that operation
#### Scenario: User must change a temporary password
- **WHEN** the current-user response sets `must_change_password`
- **THEN** the Console restricts navigation to password change and logout until password change succeeds
### Requirement: Administrators manage users from the Console
The Console SHALL expose a user-management view only to principals whose effective scopes include `users:admin`, with controls to list/create users, change role or enabled state, reset passwords, and revoke sessions.
#### Scenario: Administrator opens user management
- **WHEN** an authenticated administrator opens the Users view
- **THEN** the Console displays non-secret user records and the supported lifecycle controls without exposing password or session credential material
#### Scenario: Administrator creates an account
- **WHEN** an administrator submits valid user details and an initial password
- **THEN** the Console creates the account, clears password fields immediately, and shows that the new user must change the initial password
#### Scenario: Non-admin loads the Console
- **WHEN** the current principal lacks `users:admin`
- **THEN** the Console does not render the Users navigation or management controls, while backend authorization remains authoritative
#### Scenario: User-management mutation fails
- **WHEN** a create, update, reset, or revoke request returns validation, conflict, or authorization failure
- **THEN** the Console preserves non-secret form state as appropriate, clears all password fields, and displays the API error without retrying the mutation automatically
@@ -0,0 +1,136 @@
## ADDED Requirements
### Requirement: Persistent user accounts protect password material
The system SHALL persist uniquely identifiable human user accounts with a case-insensitive unique username, display name, fixed role, enabled state, forced-password-change state, and security timestamps, and SHALL store passwords only as salted Argon2id hashes that are never returned or logged.
#### Scenario: Administrator creates a user
- **WHEN** an authorized administrator creates a user with a username, display name, role, and valid initial password
- **THEN** the system stores the normalized unique identity and password hash, returns only non-secret account fields, and marks the account to change its initial password
#### Scenario: Username differs only by case
- **WHEN** an administrator attempts to create a username that differs from an existing username only by normalization or letter case
- **THEN** the system rejects the duplicate without changing either account
#### Scenario: Stored password needs stronger parameters
- **WHEN** a user successfully signs in and the password hasher reports that the stored Argon2id parameters are outdated
- **THEN** the system replaces the stored hash using current parameters without retaining or logging the submitted password
### Requirement: Fixed roles map to operation scopes
The system SHALL map `viewer`, `operator`, and `admin` users to the existing Cloud API scopes so resource handlers authorize user sessions and bearer principals through the same `Principal` scope checks.
#### Scenario: Viewer accesses read dashboards
- **WHEN** a logged-in `viewer` calls task-read, pool-read, or plugin-read operations
- **THEN** the request is authorized, while task submission, plugin administration, and user administration remain forbidden
#### Scenario: Operator submits a task
- **WHEN** a logged-in `operator` submits a task
- **THEN** the request is authorized in addition to all viewer read operations, while plugin and user administration remain forbidden
#### Scenario: Administrator manages users
- **WHEN** a logged-in `admin` invokes an operation requiring `users:admin` or another Cloud API scope
- **THEN** the request is authorized subject to the operation's normal validation
### Requirement: Password login creates a revocable server-side session
The system SHALL authenticate enabled users with username and password, SHALL create an opaque expiring session whose secret is stored only as a digest, and SHALL deliver the session secret in an `HttpOnly`, `SameSite` cookie that is `Secure` in production.
#### Scenario: Valid credentials create a session
- **WHEN** an enabled user submits a correct username and password within throttle limits
- **THEN** the system records a revocable session, returns the non-secret current-user representation, and sets the session and CSRF cookies with their required security attributes
#### Scenario: Credentials are not valid
- **WHEN** the username is unknown, disabled, temporarily throttled, or paired with a wrong password
- **THEN** the system returns the same generic authentication failure without revealing which condition occurred and without setting a session cookie
#### Scenario: Session is expired or revoked
- **WHEN** a request presents a session whose idle or absolute expiry has passed, whose row is revoked, or whose authentication version no longer matches the user
- **THEN** authentication fails and the system clears the browser session cookies
### Requirement: User session lifecycle is controllable
The system SHALL let a logged-in user inspect the current account, sign out, and change their own password, and SHALL revoke sessions when a password, role, enabled state, or authentication version changes.
#### Scenario: User signs out
- **WHEN** a logged-in user signs out with valid CSRF proof
- **THEN** the current session is revoked server-side and both browser cookies are cleared
#### Scenario: User changes password
- **WHEN** a logged-in user proves the current password and supplies a valid new password
- **THEN** the password hash and authentication version are updated, other sessions are revoked, and the user must continue only with a newly established valid session
#### Scenario: Temporary password requires replacement
- **WHEN** a user signs in while `must_change_password` is set
- **THEN** the session may call current-user, password-change, and logout operations but cannot access other Cloud API resources until the password is changed
### Requirement: Cookie-authenticated writes require CSRF proof
The system SHALL require a session-bound CSRF value on unsafe HTTP methods authenticated by a user cookie, while requests authenticated by an explicit bearer header SHALL remain exempt from cookie CSRF validation.
#### Scenario: Valid cookie session submits a write
- **WHEN** a cookie-authenticated request uses an unsafe method and its `X-CSRF-Token` header matches the CSRF cookie and session-bound digest
- **THEN** the request proceeds to normal scope and payload validation
#### Scenario: Cookie request omits CSRF proof
- **WHEN** a cookie-authenticated request uses an unsafe method without matching CSRF proof
- **THEN** the system rejects it before executing the operation
#### Scenario: Bearer client submits a write
- **WHEN** an authorized client sends an unsafe request with an `Authorization: Bearer` credential and no session cookie is used for authentication
- **THEN** the request is evaluated by existing bearer scope checks without requiring a CSRF header
### Requirement: Failed login attempts are throttled without permanent lockout
The system SHALL enforce a bounded failed-login window and temporary block by normalized username and trusted client-address bucket, SHALL use generic client responses, and SHALL clear applicable failure state after successful authentication.
#### Scenario: Repeated failures exceed the limit
- **WHEN** repeated failed logins for the same username and client bucket exceed the configured limit within the failure window
- **THEN** further attempts are temporarily blocked, audited, and answered with the generic authentication failure
#### Scenario: Temporary block expires
- **WHEN** the configured block duration passes
- **THEN** the account can attempt authentication again without administrator intervention
#### Scenario: Unknown username is submitted
- **WHEN** a login names no stored user
- **THEN** the system performs timing-resistant dummy password verification and applies the same throttle and response behavior as a wrong password
### Requirement: Administrators control user lifecycle
The system SHALL expose `users:admin`-protected operations to list and create users, change display name/role/enabled state, reset passwords, and revoke sessions, and SHALL prevent API actions that remove the last enabled administrator.
#### Scenario: Administrator disables an operator
- **WHEN** an administrator disables an enabled operator
- **THEN** the account can no longer authenticate and all of its active sessions are revoked
#### Scenario: Administrator resets a password
- **WHEN** an administrator assigns a valid temporary password to another user
- **THEN** existing sessions are revoked and the account is required to change that password after its next login
#### Scenario: Non-admin attempts user management
- **WHEN** a viewer, operator, anonymous caller, or Host principal calls a user-administration operation
- **THEN** the system rejects the request without exposing password or session state
#### Scenario: Last administrator would be removed
- **WHEN** an API request would disable or demote the only enabled administrator
- **THEN** the system rejects the request and preserves an enabled administrator
### Requirement: Deployment administrators can bootstrap and recover accounts safely
The system SHALL provide an administration CLI that checks the current database schema, reads new passwords interactively without echo, and can create, reset, enable, or revoke sessions for user accounts without accepting passwords in command arguments.
#### Scenario: First administrator is created in Compose
- **WHEN** a deployment administrator runs the user-create command inside the Cloud API container and enters a valid password twice at the interactive prompts
- **THEN** an enabled administrator account is created without placing the password in process arguments, Compose configuration, or command output
#### Scenario: Interactive password confirmation differs
- **WHEN** the two interactive password entries do not match
- **THEN** the CLI exits unsuccessfully without changing the account
#### Scenario: Database schema is not current
- **WHEN** a user administration CLI command runs against a database missing the required migration
- **THEN** it fails with a migration diagnostic rather than creating partial schema state
### Requirement: Authentication security events are auditable without secrets
The system SHALL record durable structured audit events for authentication and user-administration outcomes with actor, target, action, timestamp, outcome, and correlation context, and SHALL exclude submitted passwords, hashes, raw cookies, CSRF values, and bearer credentials.
#### Scenario: Login fails
- **WHEN** a login attempt fails or is throttled
- **THEN** the system records a safe failure category and correlation context without recording the submitted password or confirming whether the username exists
#### Scenario: Administrator changes a role
- **WHEN** an administrator changes a user's role
- **THEN** the system records the actor, target user id, old/new role metadata, outcome, and associated session revocation without any credential material
@@ -0,0 +1,89 @@
## ADDED Requirements
### Requirement: Versioned user authentication routes
The public API SHALL expose username/password login, logout, current-user, and password-change operations under `/v1/auth/...`, with login as the only anonymously callable user-authentication operation.
#### Scenario: Browser logs in
- **WHEN** a caller submits valid credentials to `/v1/auth/login`
- **THEN** the API establishes the secure user session and returns the user's non-secret identity, role, and effective scopes
#### Scenario: Caller gets the current user
- **WHEN** a caller presents a valid user session to `/v1/auth/me`
- **THEN** the API returns the current user's non-secret identity, role, effective scopes, and forced-password-change state
#### Scenario: Anonymous caller invokes another auth operation
- **WHEN** an anonymous caller invokes logout, current-user, or password-change
- **THEN** the API returns an authentication error without executing the operation
### Requirement: Versioned user administration routes
The public API SHALL expose user listing, creation, update, password reset, and session-revocation operations under `/v1/users/...`, all protected by `users:admin`.
#### Scenario: Administrator lists users
- **WHEN** a principal with `users:admin` lists users
- **THEN** the API returns bounded non-secret account records and no password hash, session digest, CSRF value, or login credential
#### Scenario: User administration lacks scope
- **WHEN** an authenticated principal without `users:admin` invokes any `/v1/users/...` operation
- **THEN** the API returns an authorization error before reading or changing protected user state
### Requirement: Existing bearer authentication remains compatible
The public and internal APIs SHALL continue to accept existing configured public, Host, enrollment, and dynamically enrolled Host bearer credentials with their previous scope and host-binding semantics after user authentication is enabled.
#### Scenario: CloudClient uses a configured token
- **WHEN** an existing `CloudClient` sends a valid configured public bearer token
- **THEN** its permitted resource operation succeeds without a browser session or CSRF header
#### Scenario: Host Agent uses its bearer credential
- **WHEN** an existing Host Agent authenticates to an internal Host route
- **THEN** host identity binding and authorization behave exactly as before the user system was added
## MODIFIED Requirements
### Requirement: Pluggable authentication hook with a safe default
The system SHALL evaluate every protected route through a configurable scope-aware `AuthProvider` chain that can authenticate existing bearer credentials or a valid repository-backed user session, and the deployable Cloud Control Plane SHALL reject anonymous resource access unless an explicit insecure-development override is enabled outside production.
#### Scenario: Production starts without a safe authentication mechanism
- **WHEN** the Cloud Control Plane is configured as production without user-session authentication, a usable bearer provider, or other safe public authentication provider
- **THEN** startup or readiness fails rather than exposing anonymous platform routes
#### Scenario: Explicit local anonymous override
- **WHEN** a non-production operator explicitly enables the insecure anonymous-development override
- **THEN** platform routes may use an anonymous principal and the application records that insecure mode is active
#### Scenario: Custom AuthProvider is honored
- **WHEN** a caller configures a custom `AuthProvider` that rejects a request or omits its required scope
- **THEN** the platform SDK route returns an authentication or authorization error without executing its handler operation
#### Scenario: User session provider is honored
- **WHEN** the authentication chain resolves a valid user session to a principal with the required scope
- **THEN** the protected route authorizes that principal through the same scope check used for bearer callers
### Requirement: Python SDK client mirrors the REST API
The system SHALL provide a Python `CloudClient` exposing methods corresponding to the `/v1/...` resource, user-authentication, and user-administration routes, with cookie persistence for login sessions and continued support for injected bearer authentication.
#### Scenario: Client submits a task and retrieves status
- **WHEN** a caller uses `CloudClient` to submit a task and then fetch its status by the returned id
- **THEN** the client's methods produce the same result as calling the corresponding `/v1/...` endpoints directly over HTTP
#### Scenario: Client authenticates a user session
- **WHEN** a caller uses `CloudClient` to log in with valid user credentials and then requests the current user
- **THEN** the client preserves the session cookies and returns the same non-secret user representation as the direct REST calls
#### Scenario: Bearer administrator manages users
- **WHEN** a caller configures `CloudClient` with a bearer token having `users:admin` and invokes a user-administration method
- **THEN** the client sends bearer authentication and returns the corresponding user-administration result without requiring cookie login
### Requirement: Public API operations enforce scopes
The public platform API SHALL require operation-specific scopes for task submission, task reading, pool reading, plugin reading, plugin administration, and user administration, regardless of whether the principal came from a bearer credential or user session.
#### Scenario: Submit principal has task scope
- **WHEN** a bearer or user principal with `tasks:submit` calls the task-submission endpoint
- **THEN** the request is authorized subject to normal task validation
#### Scenario: Non-admin principal attempts plugin registration
- **WHEN** an authenticated principal without `plugins:admin` calls plugin registration
- **THEN** the API rejects the request before resolving or loading the plugin target
#### Scenario: Non-admin principal attempts user administration
- **WHEN** an authenticated principal without `users:admin` calls a user-administration endpoint
- **THEN** the API rejects the request before reading or changing protected user state
@@ -0,0 +1,56 @@
## 1. User model, persistence, and migration
- [x] 1.1 Add the maintained Argon2id password-hashing dependency to the Cloud Platform package and refresh the shared lockfile
- [x] 1.2 Define user, role, session, login-throttle, and authentication-audit domain models plus the fixed role-to-scope mapping and `users:admin` scope
- [x] 1.3 Extend the persistence port with account lookup/list/create/update, password/auth-version update, session create/authenticate/revoke, throttle, audit, and bounded cleanup operations
- [x] 1.4 Add SQLAlchemy rows, uniqueness/index/foreign-key constraints, and conversion helpers for users, sessions, throttle buckets, and audit events
- [x] 1.5 Implement all user-auth persistence operations in `SQLAlchemyCloudRepository` with equivalent SQLite and PostgreSQL behavior and atomic last-enabled-admin protection
- [x] 1.6 Add Alembic revision `0003` for the user-auth tables and indexes, including an explicit destructive downgrade
- [x] 1.7 Add repository and migration tests for normalized username conflicts, session lookup/revocation/expiry, throttle windows, audit redaction fields, last-admin protection, upgrade, and downgrade
## 2. Password, session, and authentication services
- [x] 2.1 Implement the injectable Argon2id `PasswordHasher`, password policy validation, dummy verification, and successful-login rehash behavior without secret-bearing logs or representations
- [x] 2.2 Implement user creation/update/reset/change-password services with authentication-version increments, forced-password-change handling, role scopes, and affected-session revocation
- [x] 2.3 Implement opaque session and CSRF generation, digest-only persistence, idle/absolute TTL checks, bounded touch/cleanup, logout, and secure cookie set/clear helpers
- [x] 2.4 Implement temporary username/client-bucket login throttling, trusted-proxy-aware address selection, generic failures, successful-login reset, and bounded expired-state cleanup
- [x] 2.5 Implement safe authentication audit recording for login/logout/password/user/session outcomes and add tests proving passwords, hashes, cookies, CSRF values, and bearer tokens cannot enter audit payloads
- [x] 2.6 Implement `UserSessionAuthProvider` and compose user principals into the existing auth chain without changing configured bearer, Host-bound, or enrollment provider semantics
- [x] 2.7 Enforce session-bound CSRF on unsafe cookie-authenticated requests while exempting requests authenticated by an explicit bearer header
- [x] 2.8 Extend `CloudControlConfig` with bounded session/throttle/cookie/trusted-proxy settings, production-secure defaults, and validation tests
## 3. Authentication, user administration, SDK, and CLI surfaces
- [x] 3.1 Add non-secret request/response models and `/v1/auth/login`, `/v1/auth/me`, `/v1/auth/logout`, and `/v1/auth/password` handlers with forced-password-change restrictions
- [x] 3.2 Add bounded `/v1/users` list/create/update, password-reset, and session-revocation handlers protected by `users:admin`
- [x] 3.3 Wire the user-auth services and routers into Cloud API lifespan/composition so repository access remains unavailable outside lifespan and readiness reflects required schema/configuration
- [x] 3.4 Extend `CloudClient` with cookie-preserving authentication/password methods and bearer-compatible user-administration methods, including CSRF handling for session writes
- [x] 3.5 Add the `device-cloud-admin` entry point with interactive `getpass` create/reset commands plus enable and session-revoke recovery commands, schema checks, non-secret output, and no password command-line option
- [x] 3.6 Add API/SDK/CLI tests for successful and failed login, generic errors, throttle expiry, cookie flags, CSRF, session expiry/revocation, role scopes, forced password change, user lifecycle, last-admin protection, and schema failures
- [x] 3.7 Add compatibility regression tests proving existing public bearer clients, static Host credentials, dynamically enrolled Hosts, and enrollment tokens retain their previous authorization behavior
## 4. Cloud Console account experience
- [x] 4.1 Refactor `cloud-console/src/api.ts` into explicit user-session and compatibility-token modes, using `credentials: "include"`, session CSRF headers, and distinct `401` versus `403` handling
- [x] 4.2 Replace the default token gate with username/password login and a secondary “Use API token” flow that preserves the existing tab-scoped token behavior
- [x] 4.3 Add current-user initialization, authenticated account menu, logout, session-expired messaging, password change, and forced-temporary-password routing to the Console shell
- [x] 4.4 Add an admin-only Users view for bounded listing, creation, role/enabled updates, password reset, and session revocation with immediate clearing of all password fields
- [x] 4.5 Hide actions/navigation from principals lacking their required scopes while continuing to surface backend `403` responses as the authoritative decision
- [ ] 4.6 Add frontend tests for session bootstrap, login failure, CSRF write requests, `401` session loss, preserved session on `403`, token fallback, forced password change, and admin/non-admin user navigation
## 5. Packaging, deployment, and documentation
- [x] 5.1 Ensure the Python wheel and Docker image contain the Argon2 dependency, `device-cloud-admin` entry point, migration, and rebuilt Cloud Console assets
- [x] 5.2 Update Compose examples and `.env.example` with non-secret session/throttle/cookie configuration while keeping initial passwords out of environment and Compose files
- [x] 5.3 Update `docs/CLOUD_DEPLOYMENT.md` with HTTPS requirements, migration order, interactive first-admin creation, role semantics, login/session behavior, recovery, token fallback, rotation, and rollback
- [x] 5.4 Update `cloud-console/README.md` for same-origin production login and credentialed Vite development with exact-origin CORS
- [x] 5.5 Reconcile the active `cloud-console` token-only requirement before archive so account login is primary and bearer entry is explicitly compatibility-only
## 6. Verification
- [x] 6.1 Run formatting, lint/static checks, secret-focused review, and the complete non-integration Python test suite across all workspace packages
- [ ] 6.2 Run the PostgreSQL-backed repository, concurrency, and Alembic upgrade/downgrade tests for user/session/throttle/admin invariants
- [x] 6.3 Run Cloud Console dependency install, unit tests, type-check, and production build
- [x] 6.4 Run `openspec validate cloud-console-user-authentication --strict` and resolve all artifact/spec errors
- [ ] 6.5 Manually verify a Compose deployment over HTTPS: bootstrap admin, forced password change, viewer/operator/admin authorization, session expiry/revocation, login throttling, logout, and browser restart
- [ ] 6.6 Manually verify configured bearer `CloudClient` and Host Agent flows alongside user login, then inspect logs/audit rows to confirm no credential material is emitted
@@ -1,19 +1,19 @@
## ADDED Requirements
### Requirement: Operator authenticates with a bearer token
The console SHALL require an operator-supplied bearer token before calling any Cloud Control Plane endpoint, SHALL hold that token only in browser session storage, and SHALL attach it as an `Authorization: Bearer` header on every request.
### Requirement: Operator authenticates with an account session or compatibility bearer token
The console SHALL use username/password account login and a server-managed browser session as its primary authentication flow. It SHALL retain an explicit operator-supplied bearer-token compatibility path, hold that token only in browser session storage, and attach it as an `Authorization: Bearer` header on requests made in compatibility mode.
#### Scenario: No token present
- **WHEN** an operator opens the console without a previously entered token
- **THEN** the console shows a token-entry screen instead of any dashboard view
#### Scenario: No account session or token present
- **WHEN** an operator opens the console without a valid account session or previously entered compatibility token
- **THEN** the console shows username/password login with an explicit token compatibility action instead of any dashboard view
#### Scenario: Token rejected by the Cloud API
- **WHEN** the Cloud Control Plane responds `401` or `403` to a request carrying the stored token
- **THEN** the console clears the stored token and returns to the token-entry screen with a clear message
#### Scenario: Authentication rejected by the Cloud API
- **WHEN** the Cloud Control Plane responds `401` to a request carrying the active account session or stored compatibility token
- **THEN** the console clears active authentication state and returns to login with a clear message
#### Scenario: Tab closed
- **WHEN** an operator closes the browser tab running the console
- **THEN** the stored bearer token is discarded and is not available on the next visit
- **THEN** any stored compatibility bearer token is discarded and is not available on the next visit
### Requirement: Task dashboard
The console SHALL render a task view listing tasks by status with pagination, and SHALL show a task's detail including its attempt history, using the platform SDK's task-listing and attempt-history endpoints.