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

@@ -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>