Permissions are a fixed list from plugin code; grant them on Users and Groups

This commit is contained in:
2026-08-05 14:29:15 +02:00
parent fb4382be9d
commit 29d654c012
35 changed files with 431 additions and 873 deletions
+13
View File
@@ -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
+12
View File
@@ -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