Security model
Control Plane is a demo, but its security is built like it matters. This page describes the model end to end — including its limitations, because a security writeup that omits the trade-offs isn't one.
Try it live: the login page has two one-click personas — Explore the demo signs in as an OPERATOR (all jobs, audit trails), view as a restricted user signs in as a plain USER (own jobs only, no audit). The difference between those two sessions is this page in action.
Authentication
- JWT access tokens, 15-minute lifetime. HMAC-signed
(
JWT_SECRET, ≥32 bytes), carrying the user id assuband the role list as arolesclaim. Stateless: the API validates the signature, never a session store. - Refresh tokens: opaque, hashed, rotated. A refresh token is 32 random bytes; the database stores only its SHA-256 hash — a DB leak exposes nothing replayable. Every refresh rotates the token (old one revoked, new one issued), so a stolen refresh token dies the moment either party uses it. Logout revokes server-side.
- Passwords: BCrypt, with a length-only policy (minimum 12 characters) per NIST SP 800-63B — composition rules push users toward predictable substitutions; length is what costs attackers.
- Account status is enforced at every token issue point. LOCKED
and DISABLED accounts are rejected at login and refresh with a
distinct 403 (
ACCOUNT_LOCKED/ACCOUNT_DISABLED) — deliberately distinguishable from 401 invalid-credentials, because the caller proved who they are; the account is what's blocked.
Authorization
Three roles, enforced in the service layer (not just controller annotations), so the rules hold regardless of entry point:
| USER | OPERATOR | ADMIN | |
|---|---|---|---|
| Jobs & schedules | own only | all | all |
| Audit trail | — | read | read |
| User management | — | — | full |
- The JWT
rolesclaim is the single source of truth. The UI decodes the same claim the API authorizes against, so the client never renders a button the server would 403. Role changes take effect at the next token refresh — the UI says so where it matters. - User management is admin-only, with no self-service
registration. Accounts exist because an ADMIN created one (or
bootstrap seeded it). Admins cannot modify their own status or
roles (409
SELF_MODIFICATION) — no locking yourself out, no dropping the last ADMIN by accident; a second admin can always act on the first. - Locking a user ends their sessions. Every outstanding refresh token is revoked on LOCK/DISABLE; the account is dead at the next rotation. (A held access token rides out its ≤15 minutes — see limitations.)
Audit
Every security-relevant transition writes an audit row: logins (including failures, recorded in an independent transaction so the row survives the request's rollback), token refreshes, logouts, user creation, role and status changes, and the whole job/schedule lifecycle. Rows carry actor, target, JSON metadata (e.g. role before→after), client IP (X-Forwarded-For aware), and user agent. Reading audit requires OPERATOR or ADMIN.
Transport & infrastructure
- TLS by default: Caddy provisions and renews Let's Encrypt
certificates; the
.devTLD is HSTS-preloaded in browsers, so an insecure connection to the public demo is not even attemptable. - Minimal exposure: only Caddy binds host ports. Postgres, Redis,
RabbitMQ, and the API — actuator endpoints included — exist only on
the internal compose network. The proxy forwards
/api/*and nothing else. - Hardened responses: HSTS,
X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy,Serverheader stripped. - Containers: the API runs as a non-root user; secrets arrive via
environment with fail-fast
${VAR:?}guards so a half-configured deployment refuses to boot rather than booting open.
Known limitations (by design, at this scale)
- Stateless access tokens can't be recalled. Locking a user kills refresh, not the in-flight access token — worst case ≤15 minutes of residual access. The fix (a token denylist or short-TTL introspection cache in Redis) isn't warranted for a demo.
- No login rate limiting. Failed logins are audited (with IP) but not throttled. A real deployment would add a limiter at Caddy or on the auth endpoints.
- Tokens live in
localStorage. Simpler than an httpOnly-cookie flow and fine for a demo; it does mean an XSS bug could read them. The CSP-hardened, cookie-based variant is the production upgrade. - The demo accounts are deliberately public. That's the feature. Private deployments blank the bootstrap demo vars (prevents creation) and lock any already-created personas from the admin Users page (see Deployment).