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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user