Skip to content

ADR-0017: Web console

  • Status: Accepted (owner decisions, 2026-10-14)

The brief’s “dashboard” meant Grafana over the audit data (ADR-0014). The owner also wants a web console: admins see users, sessions, resources, workers, and the control plane’s state, and do operational work; ordinary users see the resources they can access.

  • Read-mostly, YAML stays the source of truth. The console shows users, roles, mappings, resources, workers, and the YAML behind them. It doesn’t author policy in forms. Applying policy remains limen admin apply (or the equivalent API call from CI). The console can run a dry run from uploaded or pasted YAML and show the diff. This keeps GitOps intact and every change attributable.
  • First version covers: users and their sessions (active and past, with recordings played in the browser), workers and tunnels, join token creation and worker revocation, policy browsing with dry-run diff, and lock a user / end a session.
  • Lock and session kill are new features, not UI. They need: a locks table (user, reason, by, at), a check at login and on every session grant, a proxy and worker path that ends open sessions when a lock appears (the policy snapshot carries active locks), and user.lock / session.kill audit events. The design had only an extension point for this.
  • Server-rendered and built into the control plane. axum with templates and a small amount of JavaScript, shipped in the same binary and image. No Node build, one artifact to deploy, a small attack surface. Assets are embedded and served with a strict CSP (no inline script, no third-party origins).
  • Who sees what. Admins (limen:admin) see everything. Everyone else sees their own identity, roles, certificate expiry, the resources they can access with the principals allowed on each, and their own session history. A user can’t see other users or workers.
  • Login. The console uses the same OIDC login. The browser flow ends in a cookie session: HttpOnly, Secure, SameSite=Lax, bound to the login ID and expiring with the user’s certificate lifetime. State-changing requests carry a CSRF token and require Origin to match. The console reuses the CA-issued user certificate only server-side. No private key reaches the browser.
  • Where it’s served. On the proxy’s public listener under /console/ (ALPN h2/http/1.1), relayed to the control plane like other API calls. The control plane has no public endpoint (ADR-0005).
  • Audit. Every console action that changes anything is a normal audit event, with the console as the client.

Phase 3.5, right after Phase 3. It needs the audit pipeline (activity, recordings stored off the worker), so building it earlier would mean redoing its data layer.

  • Lock and session kill pull a policy-snapshot extension and a proxy and worker enforcement path into Phase 3.5. They get their own tests, including “kill a live SSH session”.
  • Browser-facing surface is new: XSS, CSRF, clickjacking, and session fixation join the threat model, with CSP, frame denial, and cookie hardening as mitigations.
  • Recording playback in the browser needs an asciicast player. We’ll vendor one (asciinema-player, Apache-2.0) after checking its maintenance and advisories, per ADR-0012.

Amendment: locks and session kill (built, Phase 3.5)

Section titled “Amendment: locks and session kill (built, Phase 3.5)”
  • Tables user_locks (one active lock per user, kept after release for history) and session_kills (pruned after 2 days).
  • The signed policy snapshot carries locked_users and killed_sessions, read from the database on every snapshot request. Enforcement points: the control plane at login and when it issues SSH certificates (immediate); the proxy when establishing a session and, once a second, over every open session it relays (so a lock or kill bites within about 11 s); the worker when it opens a session.
  • Audit: user.lock, user.unlock, session.kill; session.end carries the reason (user locked / session killed).
  • CLI: limen admin lock|unlock|locks|kill-session. Runbook: docs/runbooks/lock-user.md.
  • A session killed on a proxy that has lost the control plane is only ended once that proxy gets a snapshot with the kill. Locks that already exist keep being enforced from the cached snapshot.

Amendment: the console as built (Phase 3.5)

Section titled “Amendment: the console as built (Phase 3.5)”
  • Served by the control plane on its existing mTLS listener, merged with the gRPC routes. Only a proxy may call it (proxy_only checks the proxy certificate and the forwarded marker). The proxy now accepts http/1.1 as well as h2 ALPN so browsers work, and relays /console/* with its query string, hop-by-hop headers stripped, and no client certificate.
  • Login is OIDC authorization code with PKCE as a confidential client (--console-oidc-client-id, --console-oidc-client-secret, --console-public-url; HTTPS required). A short-lived cookie ties the callback to the browser that started it, so a callback URL can’t be fed to a victim (login CSRF), and each login state is single use.
  • Session cookie __Host-limen_console (HttpOnly; Secure; SameSite=Lax; Path=/, no Domain), a random 256-bit token whose hash is in console_sessions. It expires after --console-session-ttl (default 8 h, never more than --max-cert-ttl); the CLI’s short-lived certificate is a separate thing. Locking a user deletes their browser sessions, and every request re-checks the lock.
  • CSRF: every state change is a POST with a per-session token and an Origin (or Referer) equal to the public origin.
  • CSP denies everything except same-origin scripts, styles, images, and fetches. It adds 'wasm-unsafe-eval' (the recording player’s terminal emulator is WebAssembly; this doesn’t allow JS eval), worker-src blob:, and inline style attributes (the player styles terminal cells). No inline scripts, no third-party origins. Also frame-ancestors 'none', nosniff, no-store, HSTS, and same-origin policies.
  • Data: users (filled at login), sessions (a projection of session.* events by a spool consumer, so it is idempotent and tolerates reordering), console_sessions, console_logins. A session without an end event for 24 h is shown as “unknown” rather than active.
  • Recordings are read from the recording store, and their SHA-256 is checked against the hash the worker reported at session end before they are served. A mismatch is refused.
  • Admin actions (lock, unlock, kill, revoke, create token) run through the same code as the CLI (services::ops), so the API and the console write identical audit events, distinguished by data.via (api or console). The console refuses to lock your own account.
  • Worker revoke is new: limen admin revoke / the Workers page sets components.revoked_at; the signed snapshot carries revoked_components; proxies refuse and drop that worker’s tunnels (within about 10 s) and end sessions routed through it.
  • Not built: policy editing (by decision), per-user lock expiry, a console for Kubernetes or database sessions (the protocols don’t exist yet), and a “force sign-out everywhere” button (locking does it).

/console/resources lists every resource in the active policy (admins) or only those the signed-in user’s roles allow (everyone else), with a kind filter and a search over names and labels. Filters run after access control, so a search can’t reveal a hidden resource. Admins also see each resource’s target, the roles that grant access, the live workers whose labels satisfy its selector (a resource with none is flagged, because its sessions will fail), and session counts; non-admins never see internal addresses. /console/resources/<name> answers 404 for resources a user can’t reach, the same as for ones that don’t exist.

  • Light and dark follow the system by default and can be overridden with the toggle in the sidebar (saved in localStorage; applied by an external script in <head> before first paint, so there is no flash and no inline script). Both themes are tuned for contrast; the brand teal is the accent.
  • Inter and JetBrains Mono (variable, latin subset, OFL) and a Lucide icon sprite (ISC) are embedded in the binary, so the CSP stays font-src 'self'. Vendored files and licenses are listed in crates/limen-control-plane/assets/vendor/README.md.
  • Static assets are fingerprinted (?v=<hash of all embedded files>) and cached as immutable, so an upgrade can never pair new HTML with an old stylesheet.
  • Pages declare darkreader-lock because they handle theming themselves; extensions that force dark mode otherwise re-darken the explicit light theme and rewrite its CSS.
Section titled “Amendment: audit trail, outstanding tokens, dashboards link, session length”
  • Audit trail page (/console/audit, admins). A table audit_events is filled by the same console spool consumer. It keeps the event id, time, kind, user, session, and a summary built from a fixed list of fields (action, target_user, component_id, kind, resource, login, reason, by, via, worker_id; at most 300 characters). Statements, claims, groups and any other payload never reach it. Rows older than 30 days are deleted by the cleanup job. It is a convenience copy: the sinks hold the complete, signed events, and the page says so.
  • Outstanding join tokens are listed on the Workers page (kind, labels, creator, expiry). Token values were never stored, so they can’t be shown.
  • Built-in dashboards (/console/dashboards, admins): charts drawn on the server as plain SVG (no charting library, no script, so the CSP is unchanged) from the console’s own sessions and audit_events tables, for 24 hours, 7 days or 30 days: sessions over time, sign-ins next to denials, busiest resources and people, session length, denial reasons, admin actions, sessions by kind, how sessions ended, and data transferred. Security panels: most refused people, out-of-hours access, longest sessions, accounts used, client addresses, and finished sessions without a recording. Reliability panels: components gone quiet, tunnels per worker, and database, Kubernetes and web-app errors. The page offers named views (Overview, Security, Reliability, Bots, Usage, Everything) and a “Choose panels” form (?p=<panel> once per panel). Both are plain GET links, so a chosen set can be bookmarked. Drill-down: every tile, bar and ranked row links to the records behind it (for the same period), so a number can be traced to its cause. The audit trail filters by kind (or a prefix such as auth.*), user, actor (people or bots), text in the details, session, and a UTC time range; the sessions list filters by user, resource, kind, actor, account, client address, how it ended, bot repository, and a time range. Filters are plain GET parameters (bookmarkable), invalid values are reported and ignored, and each applied filter shows as a chip that removes just that one. An audit row’s “trail” link lists every event of that session. Grafana is optional and separate: the dashboards in dashboards/grafana read the audit store for the full history. The console doesn’t link to Grafana.
  • Session length: browser sign-ins now last --console-session-ttl (default 8 h, capped by --max-cert-ttl) instead of the role’s certificate lifetime. Locks, kills and every-request lock checks are unchanged, so a long session doesn’t outlive an admin’s decision.