Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface
This commit is contained in:
+22
-5
@@ -21,8 +21,14 @@ import { KratosError, type Flow, type FlowType, type KratosPublic, type Session,
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
|
||||
// operator would after copying it into plugins/.
|
||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||
|
||||
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
|
||||
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
|
||||
@@ -532,10 +538,11 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
||||
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||
|
||||
// The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
|
||||
// anonymous is bounced to sign in before any page renders (gate on /dashboard).
|
||||
const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
||||
assert.match(await admin.text(), /href="\/admin\/users"/);
|
||||
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
||||
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
||||
// composition + role-filtering is covered in the admin-screen tests below.
|
||||
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
||||
assert.equal(dash.status, 200);
|
||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||
assert.equal(anonDash.status, 303);
|
||||
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
|
||||
@@ -852,7 +859,7 @@ const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockK
|
||||
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||
const ADMIN_CSRF = "admin-secret";
|
||||
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), ...opts });
|
||||
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
@@ -1093,6 +1100,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
||||
|
||||
await assertAdminGate(url, get, "/admin/users");
|
||||
|
||||
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered
|
||||
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment.
|
||||
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
||||
|
||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||
const listHtml = await (await get("/admin/users")).text();
|
||||
assert.match(listHtml, /ada@example\.com/);
|
||||
@@ -1111,6 +1123,11 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
||||
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
||||
// collection: a delete with no token is refused (403) and removes nothing.
|
||||
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||
|
||||
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||
const target = store[0]!;
|
||||
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
||||
|
||||
+14
-57
@@ -3,11 +3,6 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
|
||||
import { type AdminClientsDeps, handleAdminClients } from "../admin/admin-clients.ts";
|
||||
import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
|
||||
import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
|
||||
import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type User } from "./context.ts";
|
||||
@@ -30,6 +25,7 @@ import { resolveLoginChallenge } from "../auth/oauth-login.ts";
|
||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
@@ -80,6 +76,12 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
|
||||
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
|
||||
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
|
||||
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
||||
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
||||
: undefined;
|
||||
// Silent default so unit/integration tests stay quiet; server.ts injects the configured logger.
|
||||
const log = options.log ?? createLogger({ level: "none" });
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
@@ -99,8 +101,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
||||
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
||||
|
||||
// `views: [viewsDir]` lets a view in a subfolder (e.g. admin/users.ejs) include() the shared
|
||||
// partials/ by the same root-relative name top-level views use (EJS tries relative first).
|
||||
// `views: [viewsDir]` lets a view in a subfolder (e.g. partials/…) include() the shared partials/
|
||||
// by the same root-relative name top-level views use (EJS tries relative first).
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||
|
||||
@@ -108,15 +110,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
// Built-in admin screens — wired only when their Ory clients are present (the writes go
|
||||
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
|
||||
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
|
||||
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
|
||||
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
|
||||
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
|
||||
// OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
|
||||
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
|
||||
|
||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
@@ -190,8 +183,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||
|
||||
// base context (no route params yet); reused for onRequest + the built-in admin screens.
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
// base context (no route params yet); reused for onRequest hooks and the landing routes.
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
@@ -210,7 +203,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf });
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
||||
@@ -226,42 +219,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
|
||||
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
|
||||
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
|
||||
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
|
||||
const result = await handleAdminUsers(ctx, csrf.token, adminDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminGroupsDeps && pathname.startsWith(ADMIN_GROUPS_BASE)) {
|
||||
const result = await handleAdminGroups(ctx, csrf.token, adminGroupsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminRolesDeps && pathname.startsWith(ADMIN_ROLES_BASE)) {
|
||||
const result = await handleAdminRoles(ctx, csrf.token, adminRolesDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (adminClientsDeps && pathname.startsWith(ADMIN_CLIENTS_BASE)) {
|
||||
const result = await handleAdminClients(ctx, csrf.token, adminClientsDeps);
|
||||
if (result) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
await sendResult(res, result, () => Promise.reject(new Error("admin screens return html, not view")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||
const flowType = AUTH_FLOWS[pathname];
|
||||
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
|
||||
@@ -481,7 +438,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
|
||||
if (homePlugin) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const result = (await homePlugin.home(homeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
@@ -503,7 +460,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// A plugin may fully own the dashboard: render its handler against its own views, native
|
||||
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
|
||||
if (dashboardPlugin) {
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
|
||||
@@ -46,6 +46,14 @@ test("buildContext defaults a missing request URL to /", () => {
|
||||
assert.equal(buildContext(req, res).url.pathname, "/");
|
||||
});
|
||||
|
||||
test("buildContext exposes ctx.system only when the host supplies it (else undefined)", () => {
|
||||
const { req, res } = reqRes("/admin/users");
|
||||
assert.equal(buildContext(req, res).system, undefined); // absent by default — a plugin must degrade
|
||||
const revoke = (): void => {};
|
||||
const system = { revoke };
|
||||
assert.equal(buildContext(req, res, { system }).system, system); // threaded through unchanged
|
||||
});
|
||||
|
||||
test("buildContext provides a logger: a silent default, or the host's request logger", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||
import { createLogger, type Log } from "../logger.ts";
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
@@ -27,6 +28,9 @@ export interface RequestContext {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
||||
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||
system?: SystemCapabilities;
|
||||
url: URL;
|
||||
user: User | null;
|
||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||
@@ -41,6 +45,7 @@ export interface BuildContextOptions {
|
||||
chrome?: () => PageChrome;
|
||||
log?: Log;
|
||||
params?: Record<string, string>;
|
||||
system?: SystemCapabilities;
|
||||
user?: User | null;
|
||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export function buildContext(
|
||||
req,
|
||||
res,
|
||||
roles: user?.roles ?? [],
|
||||
...(options.system ? { system: options.system } : {}),
|
||||
url,
|
||||
user,
|
||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||
|
||||
Reference in New Issue
Block a user