From dfb043c3bdec673036064e8b4f3a7f76822b8203 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 12:32:51 +0200 Subject: [PATCH] Read one gate everywhere, and check a declaration's gate in one pass --- src/auth/login.ts | 5 +++- src/http/app.ts | 20 ++++++++------ src/http/builtin-routes.ts | 5 +++- src/plugin-host/discovery.ts | 49 ++++++++++++----------------------- src/plugin-host/plugin-api.ts | 2 ++ 5 files changed, 38 insertions(+), 43 deletions(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index d13d332..9f15624 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -88,7 +88,10 @@ export async function remintSession(deps: LoginDeps, cookie: string | undefined, const completed = await completeLogin(deps, cookie); // No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an // empty email reads as anonymous in the shell, and is a blank key to whatever scopes on it. - if (!completed?.email) return { setCookie: clearSessionCookie(options), user: null }; + if (!completed?.email) { + if (completed) currentLog()?.warn("session dropped: identity has no email", { sub: completed.userId }); + return { setCookie: clearSessionCookie(options), user: null }; + } return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } }; } diff --git a/src/http/app.ts b/src/http/app.ts index 9d21a78..0903df8 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -28,7 +28,7 @@ import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import type { PluginSettings } from "../plugin-host/settings.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts"; -import { allows } from "../auth/gate.ts"; +import { allows, type Gate } from "../auth/gate.ts"; import { allowedMethods, matchRoute } from "../plugin-host/router.ts"; import { buildAuthRoutes } from "../auth/routes.ts"; import { securityHeaders } from "./security-headers.ts"; @@ -157,7 +157,6 @@ export function createApp(options: AppOptions = {}): Server { // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // starter page. const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise => { - if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 }; // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. csrf.setCookie(); if (dashboardPlugin) { @@ -175,7 +174,7 @@ export function createApp(options: AppOptions = {}): Server { const builtinRoutes: BuiltinRoute[] = [ ...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }), { handler: serveHome, method: "GET", path: "/" }, - { handler: serveDashboard, method: "GET", path: "/dashboard" }, + { handler: serveDashboard, method: "GET", path: "/dashboard", session: true }, ]; // The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every @@ -279,15 +278,19 @@ export function createApp(options: AppOptions = {}): Server { } } + // Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks + // the permission gets the 403 page. + const refuse = async (gate: Gate, gateCtx: RequestContext): Promise => { + if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; } + reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id }); + sendHtml(res, 403, await renderPage("403", {})); + }; + const match = matchRoute(plugins, method, pathname); if (match) { const routeCtx = contextFor(match.plugin.id, match.params); if (!allows(match.route, routeCtx.user)) { - // Anonymous → sign in, remembering the page as return_to; a signed-in user who simply - // lacks the permission gets the 403 page. - if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } - reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id }); - sendHtml(res, 403, await renderPage("403", {})); + await refuse(match.route, routeCtx); return; } csrfMint.setCookie(); @@ -301,6 +304,7 @@ export function createApp(options: AppOptions = {}): Server { const builtin = matchBuiltinRoute(builtinRoutes, method, pathname); if (builtin) { + if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; } await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale); return; } diff --git a/src/http/builtin-routes.ts b/src/http/builtin-routes.ts index b1877a9..055ec8e 100644 --- a/src/http/builtin-routes.ts +++ b/src/http/builtin-routes.ts @@ -3,6 +3,7 @@ // mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table // after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and // pipes the result through sendResult against the core views. +import type { Gate } from "../auth/gate.ts"; import type { RequestContext } from "./context.ts"; import type { RouteResult } from "../plugin-host/plugin.ts"; @@ -19,7 +20,9 @@ export interface RequestCsrf { // own context — otherwise the plugin's keys render as bare keys on the pages it owns. export type PluginContextFactory = (pluginId: string) => RequestContext; -export interface BuiltinRoute { +// `Gate` carries `permission`/`public`/`session`, checked before the handler runs — the same rule +// the plugin router and the menu read. +export interface BuiltinRoute extends Gate { // Returns a RouteResult, or null when the handler wrote to ctx.res itself // (the landing slots dispatch a plugin's own result against that plugin's views). handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise | RouteResult | null; diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index db83441..7c137d8 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -148,57 +148,40 @@ function shapeError(manifest: PluginManifest): string | null { if (settings) return settings; } for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { - const flag = gateFlagError(`route "${route?.method} ${route?.path}"`, route); - if (flag) return flag; - const gates = gatesSet(route); - if (gates.length > 1) return `route "${route?.method} ${route?.path}" sets ${gates.join(" and ")}; a route names exactly one gate — public, session or permission`; - } - const navContradiction = findNavGateContradiction(manifest.nav); - if (navContradiction) return navContradiction; - // Every permission name the manifest mentions — gated on or declared — must be `:`. - // A bare word names a role, and roles are groups here (README → Naming a permission). - for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { - if (route?.permission != null && !isValidPermissionName(route.permission)) { - return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is :, e.g. "things:read"`; - } + const gate = gateError(`route "${route?.method} ${route?.path}"`, route); + if (gate) return gate; } + const navGate = findNavGateError(manifest.nav); + if (navGate) return navGate; for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) { if (decl?.name == null || !isValidPermissionName(decl.name)) { return `declared permission "${decl?.name}" is not :, e.g. "things:read"`; } } - const navPermission = findInvalidNavPermission(manifest.nav); - if (navPermission) return navPermission; return null; } -// A truthy non-boolean sets no gate at all, so `session: "yes"` would read as an open page. -function gateFlagError(what: string, gate: Gate | null | undefined): string | null { +// Every rule a declaration's gate must satisfy. A truthy non-boolean sets no gate at all, so +// `session: "yes"` would read as an open page; a permission name is `:` because a +// bare word names a role, and roles are groups here (README → Naming a permission). +function gateError(what: string, gate: Gate | null | undefined): string | null { for (const flag of ["public", "session"] as const) { const value = gate?.[flag]; if (value !== undefined && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``; } - return null; -} - -function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null { - for (const node of Array.isArray(nodes) ? nodes : []) { - const flag = gateFlagError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node); - if (flag) return flag; - const gates = gatesSet(node); - if (gates.length > 1) return `nav node "${node?.label ?? node?.id ?? "?"}" sets ${gates.join(" and ")}; a node names exactly one gate — public, session or permission`; - const inChild = findNavGateContradiction(node?.children); - if (inChild) return inChild; + const gates = gatesSet(gate); + if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name one gate — public, session or permission`; + if (gate?.permission != null && !isValidPermissionName(gate.permission)) { + return `${what} gates on "${gate.permission}"; a permission name is :, e.g. "things:read"`; } return null; } -function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null { +function findNavGateError(nodes: PluginManifest["nav"]): string | null { for (const node of Array.isArray(nodes) ? nodes : []) { - if (node?.permission != null && !isValidPermissionName(node.permission)) { - return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is :, e.g. "things:read"`; - } - const inChild = findInvalidNavPermission(node?.children); + const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node); + if (err) return err; + const inChild = findNavGateError(node?.children); if (inChild) return inChild; } return null; diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 03d1ec7..e3adaca 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -14,6 +14,8 @@ export type { RequestContext, User } from "../http/context.ts"; export type { PageChrome } from "../ui/chrome.ts"; export type { NavNode } from "../ui/nav.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts"; +// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it. +export type { Gate } from "../auth/gate.ts"; // Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for // authoring a plugin's own catalogs (plugins//i18n/.ts) and for building a translator // in a unit test. `PluralMessage` types a message that varies with a count.