110 lines
6.1 KiB
Markdown
110 lines
6.1 KiB
Markdown
# Account control plane
|
||
|
||
Run `python -m control` with FastAPI and uvicorn installed. Defaults to
|
||
`127.0.0.1:8092`; configure `CONTROL_HOST` / `CONTROL_PORT` explicitly if needed.
|
||
Run one control process against a database (lifecycle scheduling is process-local).
|
||
No Docker logic lives in the account API: production imports
|
||
`control.provisioner.Provisioner()` (locally aliased HermesProvisioner) lazily
|
||
on startup.
|
||
|
||
## Runtime settings
|
||
|
||
- `CONTROL_DB_PATH`: SQLite path, default `/data/control/accounts.sqlite3`.
|
||
Mount persistent private storage. The DB contains hashes and Hermes keys;
|
||
protect the directory and its WAL/SHM files, backups, and host access.
|
||
- `CONTROL_ADMIN_USERNAME`: initial administrator lowercase slug, default `admin`.
|
||
- `CONTROL_ADMIN_PASSWORD`: **required when DB is empty**, 12–256 characters;
|
||
no built-in password. Ignored once existing accounts are present. Do not log it.
|
||
- `CONTROL_SERVICE_SECRET`: >=32-character secret for trusted internal callers.
|
||
Missing/short secret disables all internal access; send as `X-Control-Secret`.
|
||
- `CONTROL_ALLOWED_ORIGINS`: exact comma-separated HTTPS browser origins.
|
||
**Set this behind nginx**; no forwarded host/proto headers are trusted.
|
||
|
||
Native login sends `Content-Type: application/json` and no browser Origin.
|
||
Browser login requires its actual Origin to match. Cookie mutations require a
|
||
matching Origin; bearer-authenticated requests are exempt. Login is throttled
|
||
per validated client IP and username (10 failed attempts / 5 minutes; successes
|
||
release their reservation). Only a direct loopback peer may supply `X-Real-IP`,
|
||
which nginx must overwrite with `$remote_addr`. Do not expose the control port
|
||
or preserve untrusted client-supplied identity headers in another proxy.
|
||
Opaque sessions last 24 hours, are hashed at rest and revoked by logout,
|
||
password reset or deletion. Cookies are Secure, HttpOnly, SameSite=Strict.
|
||
|
||
## Public API
|
||
|
||
- `POST /api/auth/login` JSON `{username,password}` →
|
||
`{token,user,expires_at}` (expiry Unix seconds) plus `hope_session` cookie.
|
||
- `POST /api/auth/logout` → `{ok:true}`; invalid sessions return 401.
|
||
- `GET /api/auth/me` → `{user}`.
|
||
- Administrator-only `GET /api/admin/users` → `{users:[...]}`.
|
||
- Administrator-only `POST /api/admin/users` JSON
|
||
`{username,display_name,password,role?:"user"}` → 201 `{user}`.
|
||
- Administrator-only `DELETE /api/admin/users/{id}` → 202 `{user}` in
|
||
deleting state. Sessions/rooms revoked synchronously before return; container
|
||
deletion is background, durable and retriable. Last administrator is protected.
|
||
- Administrator-only `POST /api/admin/users/{id}/password` JSON `{password}` →
|
||
`{ok:true}`; revokes all sessions and room grants.
|
||
- Administrator-only `POST /api/admin/users/{id}/retry` → 202 `{user}`;
|
||
retries failed provisioning or removal, preserving intended lifecycle.
|
||
- `GET /health` → `{status:"ok"}` (control HTTP liveness, not Hermes readiness).
|
||
|
||
Public user is exactly `{id,username,display_name,role,status,container_name,error}`.
|
||
IDs are stable UUID hex. Usernames match `[a-z][a-z0-9]*(?:-[a-z0-9]+)*`,
|
||
1–48 characters. Status is `provisioning`, `ready`, `error` or `deleting`.
|
||
Deleting failures remain deleting with a sanitized visible error, never become
|
||
login-eligible. Background reconcile runs on startup and every 30 seconds.
|
||
|
||
## Trusted speech boundary
|
||
|
||
Every internal route requires `X-Control-Secret`, including room lookups.
|
||
|
||
- `POST /internal/resolve-session` JSON `{token}` validates the exact cookie or
|
||
bearer token; returns `{user,hermes_url,hermes_api_key}` only for ready user.
|
||
- `GET /internal/users/{id}` returns the same ready-user metadata.
|
||
- `POST /internal/rooms` JSON `{user_id,room_name,session_token,ttl?:3600}`
|
||
validates that session belongs to the user and persists a unique server-minted
|
||
room grant, returning 201 `{room_name,user_id}`. TTL 1–86400 sec, capped at
|
||
original session expiry.
|
||
- `GET /internal/rooms/{room_name}` resolves that grant only while unexpired
|
||
and user still active/ready. Returns the same metadata. Deletion/reset revoke
|
||
grants immediately; speech agents should poll to stop active conversations.
|
||
|
||
401 means absent/invalid auth; 403 forbidden role/Origin; 404 missing/deleted
|
||
user or invalid room; 503 means not yet ready. Metadata is never in public APIs.
|
||
Do not proxy internal endpoints publicly. Token service resolves client sessions,
|
||
mints an opaque room name, persists its grant before issuing LiveKit credentials,
|
||
and never accepts browser-supplied account IDs, room ownership or Hermes URLs.
|
||
For colocated FastAPI integration use `Depends(control.current_user)` and
|
||
`Depends(control.administrator)`; these return public user dicts and require
|
||
this application's `app.state.accounts`/settings. Separate services use the
|
||
secret-authenticated internal endpoints instead.
|
||
|
||
## Provisioner interface
|
||
|
||
`create_app(settings: Settings, provisioner=...)` supports offline injection.
|
||
The adapter receives a **private trusted dict**, including `id`, `username`,
|
||
`container_name`, `desired`, and stored `hermes_url` / `hermes_api_key`.
|
||
|
||
- `async ensure(user) -> dict`: idempotently create/recover owned container;
|
||
return `{container_name,hermes_url,hermes_api_key}`. Container name must match
|
||
`hope-hermes-<username>-<id[:8]>`; metadata is persisted before readiness check.
|
||
- `async ready(user) -> bool`: owned service health, not existence alone.
|
||
- `async remove(user) -> None`: verify management/user ownership before any
|
||
removal; retain private data by default; failure raises and remains retriable.
|
||
- Optional `async aclose()`: close adapter resources during shutdown.
|
||
|
||
Async adapters offload blocking Docker/HTTP calls themselves; synchronous
|
||
adapters are supported using a worker thread. Account SQLite and scrypt work
|
||
are offloaded from the ASGI loop. Slow provision/remove run separate tasks;
|
||
request handlers do not await container startup. Exception text is not logged or
|
||
returned because provider failures may contain credentials.
|
||
|
||
Offline tests:
|
||
|
||
```sh
|
||
python -m pytest tests/unit/test_accounts.py --confcutdir=tests/unit -q
|
||
```
|
||
|
||
`--confcutdir` avoids this repository's deployment-oriented top-level fixtures.
|
||
Use a temporary DB and fake adapter; no Docker or provider calls are made.
|