Skip to content

0010: Console request path, CSRF and sandbox credentials

Date: 2026-10-07 · Status: accepted

Context

The design requires p99 page render under 50 ms with 1,000 targets and 100,000 backups, no client-side framework, every write audited, and a query console against sandboxes only. keeper-api has no RBAC in sandbox namespaces.

Decision

  • Reads never list from the API server. Informer events feed an in-process index of BackupTargets, Backups, Restores and Sandboxes. Requests read the index and the in-memory catalog. All target views are built once per change (catalog version + index version, at most every 2 s) and shared by requests, which only filter them by the caller's org/project scope.
  • The overview's 1,000-row table is rendered by a direct HTML writer. It escapes like html/template. All other markup uses html/template. Measured (test/ui TestConsoleLatency, 1,000 targets, 100,000 backups): overview p99 ≈ 16 ms, JSON target list p99 ≈ 34 ms.
  • Writes go through one path for the console (form posts, HTMX) and the API (JSON):
  • Validate, check the role (mode-dependent for restores), create or patch a Keeper resource, audit.
  • Restores are created in the target's namespace. Pins create or patch a Backup object that mirrors the stored backup (spec.backupID).
  • Backup-now and verify-now set annotations that the controller turns into objects with predictable names.
  • CSRF. Form posts must be same-origin (Sec-Fetch-Site/Origin). The JSON API needs Content-Type: application/json. Behind Cloudflare Access both also need a valid Access token.
  • No inline scripts. CSP default-src 'self'. HTMX and its SSE extension are embedded and served with immutable caching.
  • Sandbox credentials. The controller writes a copy of each sandbox's credentials to keeper-sbx-<name> in Keeper's namespace. keeper-api reads that copy for the query proxy and the audited credentials endpoint (owner or restorer-admin). keeper-api gets no access to sandbox namespaces.
  • Query console. Postgres runs BEGIN READ ONLY with statement_timeout 30 s. MySQL runs START TRANSACTION READ ONLY with max_execution_time 30 s. Writes are possible only with an explicit flag (sandboxes are disposable). Results are capped at 1,000 rows and cells at 4 KiB. The target's mask patterns are applied. Every query is audited. Per-user history is kept in memory; the audit log is the durable record.

Consequences

Pages reflect cluster changes within an informer round trip. A replica serves only what its own informers saw, and replicas converge. The in-memory audit view covers recent events on that replica; the store's audit/ objects are the complete log.