Gate a route or nav node on a session, not only a permission #103
+4
-1
@@ -88,7 +88,10 @@ export async function remintSession(deps: LoginDeps, cookie: string | undefined,
|
|||||||
const completed = await completeLogin(deps, cookie);
|
const completed = await completeLogin(deps, cookie);
|
||||||
// No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an
|
// 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.
|
// 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 } };
|
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-8
@@ -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 { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
import type { PluginSettings } from "../plugin-host/settings.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.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 { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
import { securityHeaders } from "./security-headers.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
|
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||||
// starter page.
|
// starter page.
|
||||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
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.
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
if (dashboardPlugin) {
|
if (dashboardPlugin) {
|
||||||
@@ -175,7 +174,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const builtinRoutes: BuiltinRoute[] = [
|
const builtinRoutes: BuiltinRoute[] = [
|
||||||
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
||||||
{ handler: serveHome, method: "GET", path: "/" },
|
{ 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
|
// 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);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!allows(match.route, routeCtx.user)) {
|
if (!allows(match.route, routeCtx.user)) {
|
||||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
await refuse(match.route, routeCtx);
|
||||||
// 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", {}));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
@@ -301,6 +304,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||||
if (builtin) {
|
if (builtin) {
|
||||||
|
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
|
// 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
|
// 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.
|
// pipes the result through sendResult against the core views.
|
||||||
|
import type { Gate } from "../auth/gate.ts";
|
||||||
import type { RequestContext } from "./context.ts";
|
import type { RequestContext } from "./context.ts";
|
||||||
import type { RouteResult } from "../plugin-host/plugin.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.
|
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
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
|
// 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).
|
// (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;
|
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
||||||
|
|||||||
@@ -148,57 +148,40 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
if (settings) return settings;
|
if (settings) return settings;
|
||||||
}
|
}
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
const flag = gateFlagError(`route "${route?.method} ${route?.path}"`, route);
|
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||||
if (flag) return flag;
|
if (gate) return gate;
|
||||||
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 navGate = findNavGateError(manifest.nav);
|
||||||
|
if (navGate) return navGate;
|
||||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
||||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const navPermission = findInvalidNavPermission(manifest.nav);
|
|
||||||
if (navPermission) return navPermission;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A truthy non-boolean sets no gate at all, so `session: "yes"` would read as an open page.
|
// Every rule a declaration's gate must satisfy. A truthy non-boolean sets no gate at all, so
|
||||||
function gateFlagError(what: string, gate: Gate | null | undefined): string | null {
|
// `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) {
|
for (const flag of ["public", "session"] as const) {
|
||||||
const value = gate?.[flag];
|
const value = gate?.[flag];
|
||||||
if (value !== undefined && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
if (value !== undefined && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
||||||
}
|
}
|
||||||
return null;
|
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)) {
|
||||||
function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null {
|
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
|
function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
||||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
if (node?.permission != null && !isValidPermissionName(node.permission)) {
|
const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
||||||
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
if (err) return err;
|
||||||
}
|
const inChild = findNavGateError(node?.children);
|
||||||
const inChild = findInvalidNavPermission(node?.children);
|
|
||||||
if (inChild) return inChild;
|
if (inChild) return inChild;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type { RequestContext, User } from "../http/context.ts";
|
|||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome } from "../ui/chrome.ts";
|
||||||
export type { NavNode } from "../ui/nav.ts";
|
export type { NavNode } from "../ui/nav.ts";
|
||||||
export { can, check, GuardError, requireSession } from "../auth/guards.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
|
// 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
|
// 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.
|
// in a unit test. `PluralMessage` types a message that varies with a count.
|
||||||
|
|||||||
Reference in New Issue
Block a user