RFC-108 — Operator viewer as a public, gated surface¶
Status: Proposed Date: 2026-07-25 Epic: #1320 Depends on / relates to: ADR-114 (shared edge), ADR-116 (privilege split — public vs control api), THREAT_MODEL (T-01), the player launch (#1262).
Context¶
The operator viewer (gi-kg-viewer) is served tailnet-only (prod-podcast.tail…, HTTPS via tailscale). That created a concrete problem: its browser telemetry can't cleanly reach the tailnet-only GlitchTip without either mixed-content hacks or egressing through a public tunnel — and today it wrongly egresses through orrery's domain (telemetry.orrerylearn.com/1).
Rather than patch the telemetry routing, unify the two surfaces: make the operator viewer a public, gated surface on operator.closelistening.app, reusing the player's launch pattern (coming-soon /preview SECRET-COOKIE gate → Google OAuth → role), and consume telemetry/analytics via the public closelistening subdomains like the player. This retires the tailnet-only serve and the internal-routing problem in one move.
The load-bearing constraint — T-01¶
THREAT_MODEL T-01: the operator api container mounts /var/run/docker.sock RW and holds all 6 LLM keys. Its entire accepted mitigation (D1) is "conditional on the api being provably private… the public consumer plane is a separate least-privilege service (no docker.sock, no keys)." The pre-public gate mandates: no socket, no write-scope keys, least-privilege, authN/authZ + rate limiting.
Therefore "operator viewer public" must NOT mean "expose the privileged operator api." It means a new least-privilege public service that mounts only the safe read surface — exactly the player's model, one rung wider.
Decision (proposed)¶
1. A new least-privilege operator-public service¶
Mirror docker-compose.player-public.yml: an app-only-style container with no docker.sock, no LLM keys, cap_drop/no-new-privileges/read_only, shared read-only corpus mount. A new serve posture (PODCAST_SERVE_OPERATOR_PUBLIC=1) mounts:
- health + /api/app/* (as today), plus
- a curated safe subset of _OPERATOR_READ_ROUTES.
2. Exposed route surface — curated, not the whole read set¶
_OPERATOR_READ_ROUTES is not all read-safe. Split it:
| Expose publicly (browse/query, read-only) | Keep tailnet-only (privileged / compute / write) |
|---|---|
search, explore, relational, artifacts, query_activity |
index_rebuild (rebuilds the index — compute/write) |
corpus_* (library, media, text, metrics, coverage, persons, digest, enrichments, topic/theme clusters, trending, binary) |
ops (operational controls) |
index_stats, resilience_routes, usage_routes (read-only views) |
anything that triggers a pipeline / mutates the corpus |
The privileged operator plane (index_rebuild, ops, pipeline triggers, socket ops) stays on the tailnet-only api, unchanged.
3. Defense in depth — the gate stack¶
- Coming-soon
/previewSECRET-COOKIE gate (reuse the player's; ownOPERATOR_PREVIEW_COOKIE). Outer wall — no cookie, no app. - Google OAuth (reuse the player app).
- Role gate — operator users are admin/creator via
APP_ADMIN_EMAILS(app_roles.py,listener < creator < admin); operator routes require ≥ creator. A signed-in listener is rejected from operator routes. - CF origin-lock (ADR-118) + edge rate-limit (T-06), same as the player.
4. Telemetry → public closelistening¶
Operator viewer GlitchTip DSN → telemetry.closelistening.app/1 (the podcast's own tunnel, not orrery's). Operator Umami → a closelistening subdomain. Drop the orrery domain and the tailnet-internal-relay idea. Sourcemaps (#1271) apply here too.
Role model¶
Single identity store, one role per user (app_roles.py). Player consumers default listener; operator users are bootstrapped admin (or creator) via APP_ADMIN_EMAILS. Same Google OAuth, different authorization — the operator subdomain's routes enforce ≥ creator; the player's /api/app/* stay listener-open (as today).
Threat-model impact¶
This adds a new public tenant (the operator-public service) to the register. It passes the pre-public gate by construction: separate least-privilege container (no socket, no keys, hardened), curated read-only route surface, double gate (cookie + OAuth) + role authZ + CF origin-lock + rate limit. The privileged operator api posture is unchanged — still tailnet-only, still the basis of T-01's D1. THREAT_MODEL gets a new row (operator-public) and the "operator = tailnet-only" note is qualified: the privileged plane stays private; a least-privilege read plane is public behind the gate.
Non-goals¶
- Exposing the privileged operator api (socket/keys/triggers) — explicitly out.
- New operator or player features; changing the player.
- Self-service role assignment (admin still grants roles).
Consequences¶
Positive: one telemetry model (public closelistening), no tailnet-HTTPS-ingest hack, operator reachable from anywhere behind the gate, reuses proven player machinery. Negative: a new public attack surface (mitigated by the gate stack + least-privilege); a curated route list to maintain (a new operator-read route must be classified expose-vs-private). Neutral: the tailnet-only operator serve can be retired or kept as an admin fallback.
Alternatives considered¶
- Tailnet-internal Caddy relay for the viewer's GlitchTip DSN (the "tiny fix"): keeps the operator private, same-origin relay to GlitchTip over the tailnet. Correct and smaller, but leaves the operator surface tailnet-only and doesn't unify the surfaces. Rejected in favor of the unification (operator's call).
- Dedicated tailnet-HTTPS GlitchTip host (
glitchtip.tail…): homelab-side, cleaner ingest but doesn't make the operator public. Orthogonal. - Expose the full operator api behind the gate: violates T-01's D1 (public plane must be least-privilege). Rejected.