Read one gate everywhere, and check a declaration's gate in one pass

This commit is contained in:
2026-09-02 12:32:51 +02:00
parent 5d1e8f2309
commit dfb043c3bd
5 changed files with 38 additions and 43 deletions
+4 -1
View File
@@ -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 } };
}
+12 -8
View File
@@ -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<RouteResult | null> => {
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<void> => {
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;
}
+4 -1
View File
@@ -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> | RouteResult | null;
+16 -33
View File
@@ -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 `<resource>:<action>`.
// 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 <resource>:<action>, 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 <resource>:<action>, 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 `<resource>:<action>` 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 <resource>:<action>, 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 <resource>:<action>, 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;
+2
View File
@@ -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/<id>/i18n/<locale>.ts) and for building a translator
// in a unit test. `PluralMessage` types a message that varies with a count.