feat(manager-core): admin auth gate (Phase 3a)
Closes the regression risk of the admin API and dashboard being open
to anyone reaching the bound port. Required foundation before v1.1
data-plane services land.
Per-user accounts (admin_users), Argon2id passwords, env-var bootstrap
of the first admin that becomes inert once any admin exists, opaque
32-byte session token doubling as bearer credential, 24h sliding TTL
configurable via PICLOUD_SESSION_TTL_HOURS. is_active column lets
admins be deactivated without losing audit history; last-active-admin
guard on DELETE and on PATCH that flips is_active to false (sessions
also wiped on deactivation).
require_admin middleware fronts every /api/v1/admin/* route. The data
plane (/api/v1/execute/{id}), /healthz, /version, and user routes
stay open. picloud admin reset-password <username> subcommand handles
recovery without going through HTTP.
Dashboard gains /admin/login and /admin/admins surfaces, a top-bar
user menu, and a token store with a localStorage echo so refreshes
don't sign you out. Cookie-based auth works in parallel for non-SPA
clients.
Forward compatibility: future RBAC tables (admin_roles,
admin_user_roles) join on admin_users.id; the auth middleware is the
seam where role checks slot in. Email, 2FA, passkeys, and personal
API tokens are all additive without touching admin_users.
Blueprint §11.4 updated to reflect what actually shipped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -732,9 +732,11 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## 11.4 Admin Auth (Phase 3a)
|
||||
## 11.4 Admin Auth (Phase 3a) — Shipped
|
||||
|
||||
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Today the surface is open — anyone reaching the bound port can create, edit, and delete scripts.
|
||||
**Status**: shipped. Implementation lives in `crates/manager-core/src/{auth,auth_*,admin_user_repo,admin_session_repo,admin_users_api}.rs`; migration `0004_admin_auth.sql`.
|
||||
|
||||
**Purpose**: gate the admin API (`/api/v1/admin/*`) and dashboard (`/admin/*`) behind per-user authentication. Before this phase the surface was open — anyone reaching the bound port could create, edit, and delete scripts.
|
||||
|
||||
**Why per-user, not a shared secret**: shared admin passwords get shared between humans, leave no audit trail, and can't be revoked per-person. Per-user accounts solve all three. The initial cut deliberately stops there — no roles, no per-app permissions — because that scope is small enough to ship in a single phase without blocking Phase 3b. Roles + per-app permissions are queued for v1.3+.
|
||||
|
||||
@@ -746,26 +748,29 @@ We reserve the unqualified **`users`** table for the v1.1+ Rhai SDK feature (scr
|
||||
|
||||
```sql
|
||||
CREATE TABLE admin_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- Argon2id
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL, -- Argon2id (PHC string)
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE admin_sessions (
|
||||
token_hash TEXT PRIMARY KEY, -- SHA-256 of the bearer token; raw token only exists in the login response + cookie
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
last_used_at TIMESTAMP DEFAULT NOW()
|
||||
token_hash TEXT PRIMARY KEY, -- SHA-256(hex) of the bearer token; raw token only exists in the login response + cookie
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_admin_sessions_user ON admin_sessions(user_id);
|
||||
CREATE INDEX idx_admin_sessions_expiry ON admin_sessions(expires_at);
|
||||
CREATE INDEX admin_sessions_user_idx ON admin_sessions (user_id);
|
||||
CREATE INDEX admin_sessions_expiry_idx ON admin_sessions (expires_at);
|
||||
```
|
||||
|
||||
`is_active` was added to the shipped cut so admins can be deactivated (login rejected, sessions wiped) without losing audit history; deletion still cascades sessions through the FK.
|
||||
|
||||
**Password hashing**: Argon2id with default OWASP parameters. This also resolves the v1.1+ open question about user-password hashing (§10) — the platform settles on Argon2id once, here.
|
||||
|
||||
### Bootstrap
|
||||
@@ -814,13 +819,17 @@ Companion endpoints:
|
||||
|
||||
```
|
||||
GET /api/v1/admin/admins — list
|
||||
POST /api/v1/admin/admins — create
|
||||
POST /api/v1/admin/admins — create ({ username, password })
|
||||
GET /api/v1/admin/admins/{id} — get
|
||||
PATCH /api/v1/admin/admins/{id} — update (username, password)
|
||||
DELETE /api/v1/admin/admins/{id} — delete (rejected if it would leave zero admins)
|
||||
PATCH /api/v1/admin/admins/{id} — update ({ username?, password?, is_active? })
|
||||
DELETE /api/v1/admin/admins/{id} — delete
|
||||
```
|
||||
|
||||
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet.
|
||||
Initial cut: every authenticated admin can call all of these. No self-elevation concerns because there are no privilege levels yet. The PATCH and DELETE handlers both refuse to leave the system with zero active admins (`422 Unprocessable Entity` with a clear message); PATCH that transitions `is_active` from true to false also wipes that user's sessions immediately.
|
||||
|
||||
Validation: username `^[a-z0-9._-]{2,32}$`, password minimum 8 characters (no complexity rules — follows NIST 800-63B guidance).
|
||||
|
||||
Dashboard surface: `/admin/login` (unauthed), `/admin/admins` (user list with add / change-password / deactivate / reactivate / delete actions per row). The top-bar shows the logged-in admin and a logout button. Token is held in a Svelte store with a localStorage echo so a page refresh doesn't sign you out; cookie-based auth works in parallel for non-SPA browser hits.
|
||||
|
||||
### Forward Compatibility
|
||||
|
||||
@@ -1031,7 +1040,7 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
|
||||
|
||||
Two foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
|
||||
|
||||
**3a. Admin auth** — see section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||
**3a. Admin auth** — ✓ shipped. See section 11.4. Per-user `admin_users` (not a shared secret), Argon2id passwords, env-var bootstrap of the first admin, session-token doubling as bearer token for API. No roles in this cut; schema is forward-compatible with later RBAC.
|
||||
|
||||
**3b. Multi-app scoping** — see section 11.5. Introduce `apps`, `app_domains`, and `app_id` columns on `scripts` and `routes`. Migration assigns existing data to a `default` app (or seeds a `Hello World` app on fresh installs). Orchestrator dispatch becomes two-phase (Host → app → route). Reserved internal domain (`__internal__`) keeps `/api/v1/execute/{id}/*` working for app scripts without requiring a public hostname. Dashboard becomes app-hierarchical (`/admin/apps/{slug}/...`); API keeps its existing flat shape with new app-management endpoints under `/api/v1/admin/apps/*`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user