Permissions are a fixed list from plugin code; grant them on Users and Groups
CI / full-gate (push) Successful in 2m38s
CI / full-gate (push) Successful in 2m38s
This commit is contained in:
+33
-94
@@ -15,7 +15,7 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
@@ -1250,104 +1250,43 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP
|
||||
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
|
||||
// effective-access view surfaces a user reachable only through a group.
|
||||
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
||||
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
||||
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
|
||||
// declare), so the checkboxes are a fixed list and the POST is the desired state.
|
||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const grace = randomUUID();
|
||||
const identities: Identity[] = [
|
||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||
];
|
||||
// grace is in the `eng` group; `docs:write` is an existing permission whose only direct member is ada.
|
||||
const tuples: RelationTuple[] = [
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
||||
{ namespace: "Permission", object: "docs:write", relation: "granted", subject_id: `user:${ada}` },
|
||||
];
|
||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
||||
children: tuples
|
||||
.filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation)
|
||||
.map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))),
|
||||
tuple: { namespace: "", object: "", relation: "", subject_set: set },
|
||||
type: "union",
|
||||
});
|
||||
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
|
||||
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||
|
||||
await assertAdminGate(url, get, "/admin/permissions");
|
||||
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||
const edit = await (await get(`/admin/users/${ada}`)).text();
|
||||
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
||||
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||
}
|
||||
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
||||
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
||||
|
||||
// List: the existing permission shows + the "add" link.
|
||||
const listHtml = await (await get("/admin/permissions")).text();
|
||||
assert.match(listHtml, /href="\/admin\/permissions\/docs%3Awrite"/);
|
||||
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
|
||||
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(saved.status, 303);
|
||||
assert.deepEqual(
|
||||
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||
["groups:read", "users:write"],
|
||||
);
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
||||
|
||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
|
||||
const created = await post("/admin/permissions", `_csrf=${token}&name=docs%3Aread&member=user:${ada}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/permissions/docs%3Aread");
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:read" && tp.subject_id === `user:${ada}`));
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
|
||||
// A crafted POST can't grant something no plugin declares.
|
||||
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
||||
// A bare word has no <resource>:<action> shape — the rule the create form now enforces.
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400);
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=docs%3Awrite&member=user:${ada}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
||||
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
||||
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
||||
const detail = await (await get("/admin/permissions/docs%3Awrite")).text();
|
||||
assert.match(detail, effectiveLi("ada@example.com"));
|
||||
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
||||
|
||||
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
|
||||
await post("/admin/permissions/docs%3Awrite/members", `_csrf=${token}&member=group:eng`);
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write" && tp.subject_set?.object === "eng"));
|
||||
const withGroup = await (await get("/admin/permissions/docs%3Awrite")).text();
|
||||
assert.match(withGroup, effectiveLi("grace@example.com"));
|
||||
|
||||
// Revoke the group membership.
|
||||
await post("/admin/permissions/docs%3Awrite/members/delete", `_csrf=${token}&member=group:eng`);
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write" && tp.subject_set?.object === "eng"));
|
||||
|
||||
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
||||
await post("/admin/permissions/docs%3Awrite/members", `_csrf=${token}&member=user:${grace}`);
|
||||
await post("/admin/permissions/docs%3Awrite/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
||||
|
||||
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
assert.match(await (await get("/admin/permissions/docs%3Awrite/delete")).text(), /Cancel/);
|
||||
const del = await post("/admin/permissions/docs%3Awrite/delete", `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.equal(del.headers.get("location"), "/admin/permissions");
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "docs:write"));
|
||||
|
||||
// Self-protection: permissions:write can't be deleted — without it nobody could grant anything
|
||||
// again — nor can you revoke your own direct grant of it (sub admin1).
|
||||
tuples.push({ namespace: "Permission", object: "permissions:write", relation: "granted", subject_id: "user:admin1" });
|
||||
assert.equal((await post("/admin/permissions/permissions%3Awrite/delete", `_csrf=${token}`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "permissions:write"));
|
||||
assert.equal((await post("/admin/permissions/permissions%3Awrite/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "permissions:write" && tp.subject_id === "user:admin1"));
|
||||
|
||||
// A permission written before the <resource>:<action> rule stays addressable, so it can be cleaned up.
|
||||
tuples.push({ namespace: "Permission", object: "legacy", relation: "granted", subject_id: `user:${ada}` });
|
||||
assert.equal((await get("/admin/permissions/legacy")).status, 200);
|
||||
assert.equal((await post("/admin/permissions/legacy/delete", `_csrf=${token}`)).status, 303);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "legacy"));
|
||||
|
||||
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
|
||||
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
|
||||
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||
});
|
||||
|
||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||
|
||||
+6
-3
@@ -25,7 +25,7 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
|
||||
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
||||
import { remintSession } from "../auth/login.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
|
||||
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
@@ -99,6 +99,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
// The permission catalog is a property of the installed plugin set, so it is computed once at
|
||||
// wiring rather than per request.
|
||||
const permissionCatalog = declaredPermissions(plugins);
|
||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
@@ -297,9 +300,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
||||
// own catalog is what `ctx.t` reads.
|
||||
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
renderPage = viewsFor(ctx);
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
|
||||
@@ -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 { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
@@ -37,6 +38,10 @@ export interface RequestContext {
|
||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||
log: Log;
|
||||
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
|
||||
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||
// what *this user holds*. Empty when no installed plugin declares any.
|
||||
declaredPermissions: PermissionDecl[];
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
@@ -61,6 +66,7 @@ export interface BuildContextOptions {
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
declaredPermissions?: PermissionDecl[];
|
||||
user?: User | null;
|
||||
locale?: string;
|
||||
localeHref?: (href: string) => string;
|
||||
@@ -89,6 +95,7 @@ export function buildContext(
|
||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||
return {
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
declaredPermissions: options.declaredPermissions ?? [],
|
||||
user,
|
||||
locale: options.locale ?? DEFAULT_LOCALE,
|
||||
localeHref: options.localeHref ?? ((href) => href),
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
checkApiVersion,
|
||||
declaredPermissions,
|
||||
definePlugin,
|
||||
findConflicts,
|
||||
HOST_API_VERSION,
|
||||
@@ -58,6 +59,18 @@ test("isValidPermissionName requires <resource>:<action> — a bare word names a
|
||||
}
|
||||
});
|
||||
|
||||
test("declaredPermissions is the catalog: every plugin's declarations, deduped by name and sorted", () => {
|
||||
const a: Plugin = { apiVersion: "1.0.0", id: "a", permissions: [{ description: "Write things", name: "things:write" }, { description: "Read things", name: "things:read" }] };
|
||||
const b: Plugin = { apiVersion: "1.0.0", id: "b", permissions: [{ description: "b's wording", name: "things:read" }, { name: "orders:read" }] };
|
||||
const c: Plugin = { apiVersion: "1.0.0", id: "c" }; // declaring none is fine
|
||||
|
||||
const catalog = declaredPermissions([a, b, c]);
|
||||
assert.deepEqual(catalog.map((p) => p.name), ["orders:read", "things:read", "things:write"]);
|
||||
// A shared name is legitimate (findConflicts only warns); the first declaration wins its wording.
|
||||
assert.equal(catalog.find((p) => p.name === "things:read")?.description, "Read things");
|
||||
assert.deepEqual(declaredPermissions([]), []);
|
||||
});
|
||||
|
||||
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
|
||||
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
|
||||
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
|
||||
|
||||
@@ -54,6 +54,18 @@ export function isValidPermissionName(name: string): boolean {
|
||||
return name.length <= 64 && PERMISSION_NAME.test(name);
|
||||
}
|
||||
|
||||
// Every permission the installed plugins declare, deduped by name and sorted — the fixed list the
|
||||
// admin screens offer when granting. Permissions are authored in code, never invented in the GUI, so
|
||||
// this *is* the catalog; a name in Keto that no plugin declares gates nothing and is not offered.
|
||||
// First declaration of a name wins its description (shared names are legitimate, findConflicts warns).
|
||||
export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
||||
const byName = new Map<string, PermissionDecl>();
|
||||
for (const plugin of plugins) {
|
||||
for (const decl of plugin.permissions ?? []) if (!byName.has(decl.name)) byName.set(decl.name, decl);
|
||||
}
|
||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||
export interface PluginHooks {
|
||||
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
||||
|
||||
Reference in New Issue
Block a user