fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED

m's call (2026-05-07 21:52): "remove the export variable, that is bad
form. It should be connected only to my account."

The PALIADIN_ENABLED env var was a deploy-time toggle: easy to
mis-flip, splits prod/dev behaviour, and reads as "could be turned on
for anyone." Replaced with a per-request gate in code:

  services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"

handlers/paliadin.go now gates every entry point through
requirePaliadinOwner, which looks up paliad.users.email by the caller's
UUID and returns 404 (not 403 — pretend the route doesn't exist) for
anyone else.

Routes register unconditionally; the gate is in the code, not the
deploy. main.go wires PaliadinService whenever DATABASE_URL is set and
logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED
row and gains an explanatory note about the in-code gate.

Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under
Admin) now render with display:none, revealed by sidebar.ts after
/api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL —
same fail-closed pattern the Admin group already uses.

Side-effect for ops: paliad.de production now serves the routes too,
but only to m, and only successfully if the host has tmux + claude
in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a
"tmux unavailable" — clear failure mode, not a security concern.

One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the
constant aligned with migration 023's seed so a future rename of m's
account doesn't silently strand the gate. All existing tests pass.
This commit is contained in:
m
2026-05-07 21:57:20 +02:00
parent 7b66c4d035
commit 8d714dd95e
8 changed files with 157 additions and 86 deletions

View File

@@ -47,9 +47,10 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
| `ANTHROPIC_API_KEY` | not used in PoC | Reserved for the eventual production-v1 Paliadin (the Anthropic Messages API path, see `docs/design-paliadin-2026-05-07.md` §2). The Phase 0 PoC (t-paliad-146) does NOT use this — it shells out to a local `claude` CLI via tmux instead, which uses m's existing Claude Code subscription. Set this env var only after the PoC validates and we cut over to the API-backed path. The earlier "Phase H Frist-Extraktion" reservation is dead — that feature is deferred separately (memory `b6a11b55…`). |
| `PALIADIN_ENABLED` | optional (default `false`) | Master switch for the Paliadin PoC (t-paliad-146). When `true`, the server wires `PaliadinService` and registers `/paliadin`, `/api/paliadin/*`, and `/admin/paliadin` routes. Requires a local `tmux` binary + a working `claude` CLI in PATH (the PoC orchestrates a long-lived Claude Code pane). On Dokploy prod containers, neither is present — leaving the var unset / `false` is the only correct setting. PoC scope is m's laptop only. |
| `PALIADIN_TMUX_SESSION` | optional (default `paliad-paliadin`) | tmux session name the Paliadin service uses for its long-lived `claude` pane. |
| `PALIADIN_RESPONSE_DIR` | optional (default `/tmp/paliadin`) | Directory where Claude writes its per-turn response files. The Go service polls this directory for `{turn_id}.txt` files. |
> *Note on Paliadin gating (t-paliad-146):* there is **no** `PALIADIN_ENABLED` env var. Access is gated in code via `services.PaliadinOwnerEmail` (currently `matthias.siebels@hoganlovells.com`). Every other authenticated user gets a 404 on `/paliadin` and `/admin/paliadin`. This means the routes register on every paliad deploy (including paliad.de prod), but only m can reach them — and even then, prod only works if the host has `tmux` + a `claude` CLI in PATH (which the Dokploy container does not). PoC remains a laptop-only feature; the gate is in the code, not the deploy.
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
> *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages.

View File

@@ -163,27 +163,20 @@ func main() {
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
}
// t-paliad-146 — Paliadin PoC. Only wires when PALIADIN_ENABLED=true
// is explicitly set. Default-off means production deployments
// (paliad.de on Dokploy) skip the wiring entirely; the routes
// don't even register. PoC stays on m's laptop.
if os.Getenv("PALIADIN_ENABLED") == "true" {
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir)
tmuxLabel := tmuxSession
if tmuxLabel == "" {
tmuxLabel = "paliad-paliadin"
}
respLabel := responseDir
if respLabel == "" {
respLabel = "/tmp/paliadin"
}
log.Printf("paliadin: enabled (tmux session=%q, response dir=%q) — PoC scope, m-only",
tmuxLabel, respLabel)
} else {
log.Println("paliadin: disabled (PALIADIN_ENABLED!=true) — routes will not register")
}
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
// is set; the per-request handler gate (requirePaliadinOwner)
// restricts access to the single owner email
// (services.PaliadinOwnerEmail). All other authenticated users
// get a 404 — the route effectively does not exist for them.
// On hosts without tmux + the `claude` CLI (e.g. the Dokploy
// container), the owner gate still applies; if m ever hits the
// route from such a host, the service returns "tmux unavailable"
// without ever invoking shell-out.
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir)
log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)",
services.PaliadinOwnerEmail)
// Wire ApprovalService into the entity services so Create / Update /
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
// Without this wiring, the policies and request tables exist but no

View File

@@ -72,6 +72,7 @@ export function initSidebar() {
initChangelogBadge();
initInboxBadge();
initAdminGroup();
initPaliadinLinks();
initUserViewsGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
@@ -517,6 +518,32 @@ function userViewIconSvg(icon?: string): string {
}
}
// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side).
// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5.
const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com";
// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht
// + Admin) when /api/me confirms the caller is the Paliadin owner. Same
// fail-closed display:none pattern as initAdminGroup. Non-owners never
// see the entries; the routes themselves return 404 if they navigate
// to /paliadin or /admin/paliadin manually anyway.
function initPaliadinLinks(): void {
const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null;
const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null;
if (!top && !admin) return;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((me: { email?: string } | null) => {
if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) {
if (top) top.style.display = "";
if (admin) admin.style.display = "";
}
})
.catch(() => {
// silent: failing closed is the safe default.
});
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands

View File

@@ -116,7 +116,14 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
navItem("/paliadin", ICON_SPARKLE, "nav.paliadin", "Paliadin", currentPath) +
// Paliadin entry \u2014 owner-only, hidden by default. sidebar.ts
// reveals it after /api/me confirms the caller is the
// Paliadin owner (t-paliad-146 PoC scope). Same fail-closed
// pattern as the admin group below.
`<a href="/paliadin" class="sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}" id="sidebar-paliadin-link" style="display:none">` +
`<span class="sidebar-icon">${ICON_SPARKLE}</span>` +
`<span class="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>` +
`</a>` +
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
)}
@@ -172,7 +179,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/paliadin", ICON_SPARKLE, "nav.admin.paliadin", "Paliadin Monitor", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
style="display:none">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
<span className="sidebar-label" data-i18n="nav.admin.paliadin">Paliadin Monitor</span>
</a>
</div>
</nav>

View File

@@ -450,26 +450,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /views/{slug}", gateOnboarded(handleViewsShellPage))
}
// t-paliad-146 — Paliadin (PoC). Routes register only when the
// service is wired (PALIADIN_ENABLED=true). On prod where it's
// false, paliadinSvc stays nil and these URLs simply 404.
if paliadinSvc != nil {
protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage))
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset)
// Admin dashboard (visibility self-gated to global_admin via the
// service-layer Stats query, but route is admin-only too for
// consistency with /admin/team / /admin/audit-log).
if svc != nil && svc.Users != nil {
protected.HandleFunc("GET /admin/paliadin",
auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPaliadinPage)))
protected.HandleFunc("GET /api/admin/paliadin/stats",
auth.RequireAdminFunc(svc.Users, handleAdminPaliadinStats))
protected.HandleFunc("GET /api/admin/paliadin/turns",
auth.RequireAdminFunc(svc.Users, handleAdminPaliadinTurns))
}
}
// t-paliad-146 — Paliadin (PoC). Routes register unconditionally;
// the per-request handler gate (requirePaliadinOwner) returns 404
// for any authenticated user other than services.PaliadinOwnerEmail.
// No deploy-time toggle — the gate is in the code, not in the env.
protected.HandleFunc("GET /paliadin", gateOnboarded(handlePaliadinPage))
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset)
protected.HandleFunc("GET /admin/paliadin", gateOnboarded(handleAdminPaliadinPage))
protected.HandleFunc("GET /api/admin/paliadin/stats", handleAdminPaliadinStats)
protected.HandleFunc("GET /api/admin/paliadin/turns", handleAdminPaliadinTurns)
// Catch-all 404 — runs for any authenticated path that no more-specific
// pattern claimed. Renders the chromed shell with HTTP 404 (Bug 9 from

View File

@@ -40,9 +40,36 @@ func newDetachedContext(timeout time.Duration) (context.Context, context.CancelF
}
// paliadinSvc is the live PaliadinService instance. nil when
// PALIADIN_ENABLED=false. Set by Register() at boot.
// DATABASE_URL was unset (the service depends on the audit table).
// Set by Register() at boot.
var paliadinSvc *services.PaliadinService
// requirePaliadinOwner gates every paliadin handler to the single
// owner email (services.PaliadinOwnerEmail = m). Anyone else gets a
// 404 — the gate is a "this URL doesn't exist for you" pretence
// rather than a 403, so a curious colleague can't even confirm the
// route is wired.
func requirePaliadinOwner(w http.ResponseWriter, r *http.Request) bool {
if paliadinSvc == nil {
http.NotFound(w, r)
return false
}
uid, ok := requireUser(w, r)
if !ok {
return false
}
owner, err := paliadinSvc.IsOwner(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return false
}
if !owner {
http.NotFound(w, r)
return false
}
return true
}
// pendingTurns is an in-memory map of turn_id → result channel. The POST
// /api/paliadin/turn endpoint kicks off the work + writes a synthetic
// turn record; the GET /api/paliadin/stream/{id} endpoint reads from
@@ -72,23 +99,20 @@ type turnResponse struct {
SSEURL string `json:"sse_url"`
}
// handlePaliadinPage serves the static /paliadin chat panel.
// handlePaliadinPage serves the static /paliadin chat panel. Gated to
// the single Paliadin owner (m); every other authenticated user gets
// a 404 — the route effectively does not exist for them.
func handlePaliadinPage(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "paliadin disabled — PALIADIN_ENABLED=false",
})
if !requirePaliadinOwner(w, r) {
return
}
http.ServeFile(w, r, "dist/paliadin.html")
}
// handleAdminPaliadinPage serves the /admin/paliadin monitoring page.
// Same owner gate — even other global_admins can't see this surface.
func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "paliadin disabled — PALIADIN_ENABLED=false",
})
if !requirePaliadinOwner(w, r) {
return
}
http.ServeFile(w, r, "dist/admin-paliadin.html")
@@ -100,17 +124,10 @@ func handleAdminPaliadinPage(w http.ResponseWriter, r *http.Request) {
// pushes events into the per-turn channel. The client immediately opens
// EventSource on the returned URL and reads as the goroutine writes.
func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "paliadin disabled",
})
if !requirePaliadinOwner(w, r) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
uid, _ := requireUser(w, r) // already validated by requirePaliadinOwner
var req turnRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
@@ -205,10 +222,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
// to. Reads from the per-turn channel + writes SSE-framed events.
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "paliadin disabled",
})
if !requirePaliadinOwner(w, r) {
return
}
turnIDStr := r.PathValue("id")
@@ -274,13 +288,7 @@ func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
// handlePaliadinReset clears the Claude conversation context.
func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "paliadin disabled",
})
return
}
if _, ok := requireUser(w, r); !ok {
if !requirePaliadinOwner(w, r) {
return
}
ctx, cancel := newDetachedContext(10 * time.Second)
@@ -300,14 +308,10 @@ func handlePaliadinReset(w http.ResponseWriter, r *http.Request) {
// handleAdminPaliadinStats returns the aggregate stats for the dashboard.
func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "paliadin disabled"})
return
}
uid, ok := requireUser(w, r)
if !ok {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r)
stats, err := paliadinSvc.Stats(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -318,14 +322,10 @@ func handleAdminPaliadinStats(w http.ResponseWriter, r *http.Request) {
// handleAdminPaliadinTurns returns the most recent turn rows.
func handleAdminPaliadinTurns(w http.ResponseWriter, r *http.Request) {
if paliadinSvc == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "paliadin disabled"})
return
}
uid, ok := requireUser(w, r)
if !ok {
if !requirePaliadinOwner(w, r) {
return
}
uid, _ := requireUser(w, r)
turns, err := paliadinSvc.ListRecentTurns(r.Context(), uid, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})

View File

@@ -40,6 +40,16 @@ import (
"github.com/lib/pq"
)
// PaliadinOwnerEmail is the only account allowed to use the Paliadin
// PoC. Hardcoded — by design — so the gate cannot be flipped via a
// deploy env var. PoC ships at this scope; multi-user opens up only
// when production v1 lands behind its own auth model.
//
// Matches the seed in migration 023 (m's job_title row). If m's email
// ever rotates, this constant must rotate with it; there is no other
// path to enabling Paliadin.
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
// PaliadinService manages the tmux-claude PoC.
type PaliadinService struct {
db *sqlx.DB
@@ -57,6 +67,26 @@ type PaliadinService struct {
turnMu sync.Mutex
}
// IsOwner returns true when the given user_id corresponds to m's
// account (the only Paliadin PoC user). Resolves via paliad.users.email
// rather than caching a UUID so a DB rebuild that reassigns auth UUIDs
// doesn't strand the gate.
//
// Returns (false, nil) for any other user — including unknown UUIDs and
// users without an email row. Errors only on DB failure.
func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
var email string
err := s.db.QueryRowxContext(ctx,
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("paliadin: lookup owner: %w", err)
}
return strings.EqualFold(email, PaliadinOwnerEmail), nil
}
// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true.
func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService {
if tmuxSession == "" {

View File

@@ -140,6 +140,22 @@ func TestSanitiseForTmux(t *testing.T) {
}
}
func TestPaliadinOwnerEmail_IsLowercaseStable(t *testing.T) {
// Sanity check: the constant matches the email seeded in
// migration 023 verbatim. If it ever drifts, the gate would
// reject m on a fresh DB without anyone noticing.
want := "matthias.siebels@hoganlovells.com"
if PaliadinOwnerEmail != want {
t.Fatalf("PaliadinOwnerEmail = %q; want %q (matches migration 023 seed)",
PaliadinOwnerEmail, want)
}
// Lowercase invariant — the gate uses strings.EqualFold but we
// store + compare lowercase consistently anyway.
if strings.ToLower(PaliadinOwnerEmail) != PaliadinOwnerEmail {
t.Errorf("PaliadinOwnerEmail must be lowercase: %q", PaliadinOwnerEmail)
}
}
func TestSanitiseForTmux_TruncatesLong(t *testing.T) {
long := strings.Repeat("x", 10_000)
got := sanitiseForTmux(long)