docs(pilot): dashboard now behind Logto SSO

This commit is contained in:
Claude
2026-08-22 18:05:02 +00:00
parent 99255bb167
commit 2ba3ccbd95
+130 -109
View File
@@ -4,19 +4,24 @@ A read-only overview + per-repo / per-PR drilldown over the same SQLite
feedback DB the webhook writes, plus a small form to mutate `.pr-review.json` feedback DB the webhook writes, plus a small form to mutate `.pr-review.json`
on a covered repo via the Gitea contents API. Companion to the on a covered repo via the Gitea contents API. Companion to the
[webhook service](README-webhook.md); reuses the webhook image [webhook service](README-webhook.md); reuses the webhook image
(`pragent-webhook:optin`) — the pilot modules are baked into `/app/pilot/`, (`pragent-webhook:dashboard`) — the pilot modules are baked into `/app/pilot/`,
and the dashboard is just `python3 -m pilot.dashboard`. and the dashboard is just `python3 -m pilot.dashboard`.
## Architecture ## Architecture
``` ```
Browser (tailnet) Browser
NodePort :31540 on 100.74.17.70 (kubernets) Caddy (TLS, wildcard cert via Cloudflare DNS-01)
https://pragent-dashboard.marcospaulo.dev.br → 100.74.17.70:31541
Service pragent-dashboard.pragent.svc.cluster.local (NodePort, ns pragent) Service oauth2-proxy-dashboard.pragent.svc.cluster.local (NodePort 31541, ns pragent)
│ oauth2-proxy fronts the dashboard, enforces Logto SSO + email allowlist
│ sets X-Forwarded-User / X-Forwarded-Email on accepted requests
Service pragent-dashboard.pragent.svc.cluster.local (ClusterIP, ns pragent)
pragent-dashboard pod (uid 10001, /data RO, no subprocess fan-out) pragent-dashboard pod (uid 10001, /data RO, no subprocess fan-out)
@@ -31,6 +36,10 @@ Fail-soft. Nothing is ever written to local disk by the dashboard — the
SQLite file is read-only and `.pr-review.json` mutations go through Gitea's SQLite file is read-only and `.pr-review.json` mutations go through Gitea's
contents API so the commit history records who changed what. contents API so the commit history records who changed what.
The dashboard `Service` is **ClusterIP** — only oauth2-proxy can reach it.
Public access is gated by Caddy (TLS termination) → oauth2-proxy (Logto SSO
+ allowlist) → dashboard.
## What it does ## What it does
- **Overview** (`GET /`): summary stats across all onboarded repos — total - **Overview** (`GET /`): summary stats across all onboarded repos — total
@@ -50,9 +59,6 @@ contents API so the commit history records who changed what.
API). This is how repo-local `focus` / `instructions` / API). This is how repo-local `focus` / `instructions` /
`reviewers` / `severity_threshold` get tuned per-repo after seeing `reviewers` / `severity_threshold` get tuned per-repo after seeing
the feedback roll-up. the feedback roll-up.
- **Auth** (`GET /login`, `POST /login`): single-user cookie
`pragent_dash=<token>` when `PRAGENT_DASHBOARD_TOKEN` is set in the
Secret. Unset = no auth (tailnet-only mode; logged on startup).
All routes return HTML (or plain text for `/raw`) with the same stylesheet All routes return HTML (or plain text for `/raw`) with the same stylesheet
(`/static/style.css`). (`/static/style.css`).
@@ -62,14 +68,16 @@ All routes return HTML (or plain text for `/raw`) with the same stylesheet
| method | path | auth | description | | method | path | auth | description |
|--------|-----------------------------------|------|----------------------------------------------| |--------|-----------------------------------|------|----------------------------------------------|
| GET | `/` | yes | Overview | | GET | `/` | yes | Overview |
| GET | `/login` | no | Login form |
| POST | `/login` | no | Sets the `pragent_dash` cookie |
| GET | `/static/style.css` | no | Stylesheet | | GET | `/static/style.css` | no | Stylesheet |
| GET | `/r/<owner>/<name>` | yes | Repo drilldown | | GET | `/r/<owner>/<name>` | yes | Repo drilldown |
| GET | `/r/<owner>/<name>/<index>` | yes | PR drilldown | | GET | `/r/<owner>/<name>/<index>` | yes | PR drilldown |
| GET | `/r/<owner>/<name>/<index>/raw` | yes | Most recent review body as markdown | | GET | `/r/<owner>/<name>/<index>/raw` | yes | Most recent review body as markdown |
| POST | `/r/<owner>/<name>/edit` | yes | Edit `.pr-review.json` on the default branch | | POST | `/r/<owner>/<name>/edit` | yes | Edit `.pr-review.json` on the default branch |
Auth is enforced by oauth2-proxy upstream; the dashboard itself only
checks the `X-Forwarded-User` header that oauth2-proxy sets after a
successful Logto login + email allowlist match.
There is no `/health` route — don't add one to the k8s probes without There is no `/health` route — don't add one to the k8s probes without
updating `pilot/dashboard.py` (the handler returns 404 on unknown paths, updating `pilot/dashboard.py` (the handler returns 404 on unknown paths,
so a probe would loop forever). so a probe would loop forever).
@@ -91,51 +99,66 @@ volumeMount in `~/k8s/pragent-dashboard.yaml`): the dashboard never
writes the SQLite file, only the webhook + the daily cronjob do, and writes the SQLite file, only the webhook + the daily cronjob do, and
keeping it RO means a buggy deploy can't corrupt the harvested feedback. keeping it RO means a buggy deploy can't corrupt the harvested feedback.
## Auth (single-user cookie) ## Auth (Logto SSO via oauth2-proxy)
`PRAGENT_DASHBOARD_TOKEN` in the `pragent-webhook` Secret is the value Authentication is delegated to oauth2-proxy, which fronts the dashboard
the dashboard expects on the `pragent_dash` cookie. The login form in-cluster. The dashboard never sees a cookie or a token — it only
accepts it on POST / login and sets the cookie. Empty / unset env var inspects `X-Forwarded-User` (set by oauth2-proxy after a successful
disables auth (logged on startup: `auth OFF (tailnet-only mode)`). Logto login + email allowlist match). Missing header → 401 with
`WWW-Authenticate: Basic realm="pragent-dashboard"`, which lets
oauth2-proxy intercept and bounce the browser to Logto.
Generate a token once: Email allowlist lives in the ConfigMap `oauth2-proxy-dashboard-emails`
in namespace `pragent`:
```bash ```yaml
openssl rand -hex 16 data:
authenticated-emails: |
marcos.paulodasilva.mp@gmail.com
thiago@marcospaulo.dev.br
``` ```
Add to the Secret (the dashboard reads it as `PRAGENT_DASHBOARD_TOKEN`): Edit the ConfigMap to add/remove users; oauth2-proxy hot-reloads the
file (it logs `watching ... for updates`), no restart needed. This is
the same isolation pattern as the minecraft-sso / code-server
allowlists — see `~/.claude/memory/minecraft-sso.md`.
```bash The Logto app is `pragent-dashboard` (tenant `default`, type
# base64 the token first `Traditional`), created by direct INSERT into Logto Postgres mirroring
printf '<token>' | base64 the proven `minecraft-sso` pattern. Credentials live in
# then JSON-patch the Secret `~/k8s/oauth2-proxy-dashboard-secret.yaml` (mode 600, NOT in git).
microk8s kubectl patch secret pragent-webhook -n pragent --type=json \
-p='[{"op":"add","path":"/data/PRAGENT_DASHBOARD_TOKEN","value":"<b64>"}]'
```
The token also lives in `~/.config/pragent/dashboard-token` (mode 600) so Public URL: **https://pragent-dashboard.marcospaulo.dev.br** (Caddy
you can paste it into the login form without re-reading the Secret. TLS termination via wildcard cert → Tailscale → NodePort 31541 →
oauth2-proxy → dashboard ClusterIP).
### Emergency bypass (cookie)
If Logto goes down and you need to access the dashboard before the
oauth2-proxy restart dance (see `~/.claude/memory/logto-fix.md`),
`pilot/dashboard.py` can be patched to accept a fallback cookie by
re-adding the `PRAGENT_DASHBOARD_TOKEN` env path — the route gate is
isolated in `_is_authed` and the logic is straightforward. The current
commit intentionally has no bypass because Logto SSO is the single
source of truth for "who can touch `.pr-review.json`".
## Deploy ## Deploy
The dashboard shares the webhook image, so there's nothing to rebuild. The dashboard shares the webhook image, so there's nothing to rebuild
After editing `pilot/dashboard.py` or `pilot/dashboard_data.py`, redo the beyond what the webhook already does. After editing `pilot/dashboard.py`
webhook image rebuild + containerd import (see `README-webhook.md` § "K8s or `pilot/dashboard_data.py`, redo the webhook image rebuild + containerd
deployment") and roll both deployments. import (see `README-webhook.md` § "K8s deployment") and roll both
deployments.
```bash ```bash
K="microk8s kubectl" K="microk8s kubectl"
# 1. (one-time) generate + persist the auth token # 1. (one-time) create the Logto app + cookie secret + oauth2-proxy
TOKEN=$(openssl rand -hex 16) # See ~/.claude/memory/minecraft-sso.md for the SQL INSERT recipe
printf "%s" "$TOKEN" > ~/.config/pragent/dashboard-token # and ~/k8s/oauth2-proxy-dashboard*.yaml for the manifests.
chmod 600 ~/.config/pragent/dashboard-token
printf "%s" "$TOKEN" | base64 \
| xargs -I{} $K -n pragent patch secret pragent-webhook --type=json \
-p='[{"op":"add","path":"/data/PRAGENT_DASHBOARD_TOKEN","value":"{}"}]'
# 2. apply the manifest # 2. apply all pragent-dashboard manifests (dashboard + oauth2-proxy)
$K apply -f ~/k8s/oauth2-proxy-dashboard.yaml
$K apply -f ~/k8s/pragent-dashboard.yaml $K apply -f ~/k8s/pragent-dashboard.yaml
# 3. roll on image / code changes # 3. roll on image / code changes
@@ -144,86 +167,86 @@ $K -n pragent rollout status deploy/pragent-dashboard --timeout=120s
$K -n pragent logs -f deploy/pragent-dashboard $K -n pragent logs -f deploy/pragent-dashboard
``` ```
K8s manifest at `~/k8s/pragent-dashboard.yaml`. Key fields: K8s manifests:
- `image: pragent-webhook:optin` + `imagePullPolicy: Never` — local - `~/k8s/pragent-dashboard.yaml` — Deployment + ClusterIP Service.
containerd only, same image as the webhook. - `image: pragent-webhook:dashboard` + `imagePullPolicy: Never`
- `nodeSelector: kubernetes.io/hostname: kubernets` — pinned to the node local containerd only, same image as the webhook.
holding the `/data` PVC. - `nodeSelector: kubernetes.io/hostname: kubernets` — pinned to the
- `securityContext: runAsNonRoot: true, runAsUser: 10001, runAsGroup: node holding the `/data` PVC.
10001, fsGroup: 10001` — matches the image's USER directive; fsGroup - `securityContext: runAsNonRoot: true, runAsUser: 10001, runAsGroup:
makes the RO hostpath volume readable. 10001, fsGroup: 10001` — matches the image's USER directive;
- `volumeMounts.feedback-data.readOnly: true` — dashboard is read-only fsGroup makes the RO hostpath volume readable.
over `/data`; mutations go through Gitea, not local fs. - `volumeMounts.feedback-data.readOnly: true` — dashboard is
- No `readinessProbe` / `livenessProbe` — the dashboard has no `/health` read-only over `/data`; mutations go through Gitea, not local fs.
route. If you add one to `pilot/dashboard.py`, add a probe here too. - No `readinessProbe` / `livenessProbe` — the dashboard has no
- `resources.requests: {cpu: 100m, memory: 256Mi}` / `/health` route. If you add one to `pilot/dashboard.py`, add a
`limits: {cpu: 500m, memory: 512Mi}` — read-heavy + tiny writes, no probe here too.
opencode subprocess fan-out, much smaller than the webhook. - `resources.requests: {cpu: 100m, memory: 256Mi}` /
- `Service.type: NodePort`, `nodePort: 31540` — between pages-proxy `limits: {cpu: 500m, memory: 512Mi}` — read-heavy + tiny writes,
(30081) and browserless (30100), outside the 30096..30969 media range. no opencode subprocess fan-out, much smaller than the webhook.
- `Service.type: ClusterIP` — only oauth2-proxy can reach it.
- `~/k8s/oauth2-proxy-dashboard.yaml` — Deployment + ConfigMap +
NodePort Service (`oauth2-proxy-dashboard`, NodePort 31541,
namespace `pragent`). Same shape as the code-server /
minecraft-sso oauth2-proxy. NodePort 31541 was chosen because
31540 was the old dashboard NodePort and the 30096..30969 media
range + 30350-30351 (other oauth2-proxy NodePorts) were taken.
- `~/k8s/oauth2-proxy-dashboard-secret.yaml` — client-id /
client-secret / cookie-secret (mode 600, NOT in git).
## Smoke test ## Smoke test
```bash ```bash
# 1. port-forward (in-cluster) # 1. anonymous request → 302 redirect to Logto
curl -I https://pragent-dashboard.marcospaulo.dev.br/
# 2. pod logs
microk8s kubectl logs -n pragent -l app=oauth2-proxy-dashboard --tail=50
microk8s kubectl logs -n pragent -l app=pragent-dashboard --tail=50
# 3. in-cluster direct probe (should 401 without X-Forwarded-User)
microk8s kubectl port-forward -n pragent svc/pragent-dashboard 8181:80 & microk8s kubectl port-forward -n pragent svc/pragent-dashboard 8181:80 &
sleep 2 sleep 2
curl -s -o /tmp/dash-overview.html -w "HTTP %{http_code}\n" \ curl -I http://localhost:8181/ # expect 401 + WWW-Authenticate: Basic
http://localhost:8181/ curl -I -H "X-Forwarded-User: marcos@example.com" http://localhost:8181/ # expect 200
grep -o "<title>[^<]*</title>" /tmp/dash-overview.html
head -50 /tmp/dash-overview.html
kill %1 kill %1
# 2. NodePort (host-side, Tailscale IP — only reachable on 100.74.17.70
# or 192.168.1.80; no public DNS yet)
curl -s -o /tmp/dash-nodeport.html -w "HTTP %{http_code}\n" \
http://100.74.17.70:31540/
grep -o "<title>[^<]*</title>" /tmp/dash-nodeport.html
# 3. auth: POST the login cookie value, capture into a jar
curl -sS -c /tmp/dash.jar -X POST \
-d "token=$(cat ~/.config/pragent/dashboard-token)" \
http://100.74.17.70:31540/login
# 4. authenticated overview
curl -sS -b /tmp/dash.jar -o /tmp/dash-auth.html -w "HTTP %{http_code}\n" \
http://100.74.17.70:31540/
grep -o "<title>[^<]*</title>" /tmp/dash-auth.html
# 5. pod logs
microk8s kubectl logs -n pragent -l app=pragent-dashboard --tail=50
``` ```
The HTML should contain a `<title>` (whatever the dashboard renders) and The HTML returned with a valid `X-Forwarded-User` should contain a
**never** `Traceback` or any Python exception output. A 401 / redirect `<title>` (whatever the dashboard renders) and **never** `Traceback` or
to `/login` on the unauthenticated GETs is expected when any Python exception output. A 401 on the unauthenticated GET is the
`PRAGENT_DASHBOARD_TOKEN` is set. expected behaviour — oauth2-proxy catches it and redirects to Logto.
## Threat model / security notes ## Threat model / security notes
- **Tailnet-only by default.** NodePort 31540 is exposed on the - **Behind Logto SSO.** Anonymous traffic gets 302 → Logto. Allowed
Tailscale / LAN interfaces of kubernets (100.74.17.70, 192.168.1.80). emails (marcos, thiago) reach the dashboard after Logto login; all
No public DNS, no Caddy route yet — keep it that way until the auth others see oauth2-proxy's "not authorized" page. Adding a user is a
story is solid. When you do add a public route, terminate TLS at one-line ConfigMap edit; oauth2-proxy hot-reloads the allowlist.
Caddy (wildcard cert via Cloudflare DNS-01) and rely on the cookie - **`PRAGENT_BOT_TOKEN` is Gitea Write scoped** to onboarded repos, so
auth + Logto SSO gating pattern from the a successful auth bypass on the dashboard is Gitea repo write access,
[code-server / minecraft-sso / livecodes setups](../). not just read. oauth2-proxy's email allowlist is the only
- **`PRAGENT_BOT_TOKEN` is Gitea Write scoped** to onboarded repos, so a authentication factor — there is no second factor. If this becomes a
successful auth bypass on the dashboard is Gitea repo write access, concern, swap oauth2-proxy for an IdP that supports TOTP/WebAuthn
not just read. The cookie is the only auth factor — there is no CSRF and the dashboard needs no further changes (it just reads the
token in the current implementation (single-operator trust model). If forwarded headers).
the dashboard goes public, add a CSRF token to the edit form - **CSRF on the edit form.** Per-process random secret embedded as a
(hidden input + double-submit cookie) before opening it up. hidden input + double-submit via the `X-Forwarded-User` context. An
attacker would need to (a) steal the user's Logto session cookie
from oauth2-proxy and (b) read the rendered HTML to harvest the
CSRF token. Both have to happen in the same browser.
- **Read-only `/data` mount.** The dashboard can't corrupt the - **Read-only `/data` mount.** The dashboard can't corrupt the
harvested SQLite file even if it's compromised. The webhook and the harvested SQLite file even if it's compromised. The webhook and the
daily cronjob are the only writers. daily cronjob are the only writers.
- **ClusterIP dashboard Service.** Even if a malicious actor discovered
the dashboard's container port, they cannot reach it from outside the
cluster — only oauth2-proxy can. NetworkPolicy is the cluster
default deny.
- **`uid 10001` + `runAsNonRoot: true`.** No host-level escalation if - **`uid 10001` + `runAsNonRoot: true`.** No host-level escalation if
the dashboard is popped — it has no caps, no `/proc` mounts, no the dashboard is popped — it has no caps, no `/proc` mounts.
NetworkPolicy exemption (cluster default deny applies until you
grant egress to `gitea-http.gitea.svc.cluster.local:3000` and
`100.74.17.70:8787` if you ever need the headroom proxy — currently
the dashboard doesn't).
- **No author-controlled input is `eval`-ed.** The edit form parses the - **No author-controlled input is `eval`-ed.** The edit form parses the
JSON, validates types / lengths, and re-serialises before the Gitea JSON, validates types / lengths, and re-serialises before the Gitea
PUT. The review-side hostile-input concerns from `README-webhook.md` PUT. The review-side hostile-input concerns from `README-webhook.md`
@@ -232,12 +255,10 @@ to `/login` on the unauthenticated GETs is expected when
## Known limitations (pilot) ## Known limitations (pilot)
- Single-user cookie auth (no per-user sessions, no Logto SSO yet). - Logto SSO is the only auth factor — no per-user sessions, no CSRF
- No CSRF protection on the edit form (intentional — single-operator token tied to a per-user identity (the per-process CSRF secret is
trust model; add before going public). global). Adequate for a single-operator dashboard; not adequate for
- Read-only `/data` means the dashboard can't backfill the DB if the multi-tenant.
cronjob is paused; if you turn off feedback harvesting
(`PRAGENT_FEEDBACK_DB=` empty in the webhook), the overview is empty.
- No `/health` route — if the dashboard process wedges on a Gitea hang, - No `/health` route — if the dashboard process wedges on a Gitea hang,
k8s won't restart it. Add a `/health` route to `pilot/dashboard.py` k8s won't restart it. Add a `/health` route to `pilot/dashboard.py`
+ a probe here before relying on this in production. + a probe here before relying on this in production.