feat(web): registration gate + per-request user-id seam (ADR-012)
CI / Lint / Test / Vet (push) Successful in 9s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s

Multi-user web surface. Two layered middlewares: Auth.Middleware (Dex
session required) wraps registrationGate, which resolves the authenticated
subject -> tapir user_id once per request via the new web.Identity port and
stashes it. A subject with no tapir user is redirected to GET /register
(display name + accept-terms); POST /register calls RegisterUser then
redirects to /. /register is inside the auth guard but exempt from the gate
(/auth/* and /healthz too).

Current-user seam: CurrentUserID(r) (string, bool) returns the resolved id
from the request context. The list/detail/action handlers now scope by it,
replacing the single configured App.UserID (removed). App gains an Identity
field; *store.Store satisfies both Store and Identity. cmd/tapir wires
Identity: st and drops UserID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:00:30 +02:00
co-authored by Claude Opus 4.8
parent e62df0027d
commit 7b4960e417
7 changed files with 484 additions and 72 deletions
+6 -5
View File
@@ -144,10 +144,11 @@ func cmdRun(ctx context.Context, log *slog.Logger) error {
return r.Loop(ctx, cfg.PollInterval)
}
// cmdServe runs the Stage-0 web UI: the summary reader over the existing store
// (ADR-003 — a new transport, not new core). Auth is the StubAuth allow-all seam
// keyed to the configured user; the Conductor swaps in oidc.DexAuth at merge —
// the only line that changes is the `authn` assignment below.
// cmdServe runs the Stage-1 web UI: the summary reader over the existing store
// (ADR-003 — a new transport, not new core). Auth (web.Auth) gates access; the
// registration gate resolves the authenticated subject to a tapir user_id and
// scopes every store access by it (ADR-012). With Dex configured, real OIDC login
// is used; otherwise StubAuth (dev only). The store doubles as the Identity port.
func cmdServe(ctx context.Context, log *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
@@ -185,7 +186,7 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose")
}
app := &web.App{Store: st, Auth: authn, UserID: cfg.UserID, Log: log}
app := &web.App{Store: st, Identity: st, Auth: authn, Log: log}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: app.Router(),