feat: namespace plugin permissions and keep them valid when a plugin is off

This commit is contained in:
LukeGus committed 2026-09-22 17:30:00 -05:00
1 parent 8431570ff7
commit cc15559ea1
53 files changed
+31442 -351

No files matched your search

+157 -42
View File
@@ -170,10 +170,13 @@ describe("unknown field rejection", () => {
},
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
permissions: [
{
name: "use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
},
}),
);
@@ -217,53 +220,156 @@ describe("dependencies", () => {
});
});
describe("permission groups", () => {
it("accepts a group and its declared permissions", () => {
describe("plugin permissions", () => {
it("accepts declared permissions", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
permissions: [
{
name: "use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
},
}),
),
).toEqual([]);
});
// Without this a manifest could hand any role admin.* at boot.
it("refuses a role default outside the plugin's own permissions", () => {
it("accepts a dotted name and a system role default", () => {
expect(
validateManifest(
base({
contributes: {
permissions: [
{
name: "services.use",
titleKey: "permissions.services.use.title",
descriptionKey: "permissions.services.use.description",
defaultRoles: ["admin"],
},
],
},
}),
),
).toEqual([]);
});
// Without this a manifest could name a core group and then gate a route on
// authority it was never given.
it("refuses a name starting with a core group", () => {
const errors = validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { user: ["admin.users.manage"] },
},
permissions: [
{
name: "admin.users.manage",
titleKey: "k",
descriptionKey: "d",
},
],
},
}),
);
expect(errors.join()).toMatch(/does not declare/);
expect(errors.join()).toMatch(/reserved core group/);
});
it("accepts a role default the plugin does declare", () => {
expect(
validateManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
defaultForRole: { admin: ["sample-plugin.use"] },
it("refuses a name starting with hosts", () => {
const errors = validateManifest(
base({
contributes: {
permissions: [
{ name: "hosts.view", titleKey: "k", descriptionKey: "d" },
],
},
}),
);
expect(errors.join()).toMatch(/reserved core group/);
});
// "sample-plugin" + "sample-plugin.use" would register as
// sample-plugin.sample-plugin.use, which is never the intent.
it("refuses a name that repeats the plugin id", () => {
const errors = validateManifest(
base({
contributes: {
permissions: [
{ name: "sample-plugin.use", titleKey: "k", descriptionKey: "d" },
],
},
}),
);
expect(errors.join()).toMatch(/already starts with this plugin/);
});
it("refuses a default for a role core does not seed", () => {
const errors = validateManifest(
base({
contributes: {
permissions: [
{
name: "use",
titleKey: "k",
descriptionKey: "d",
defaultRoles: ["superuser"],
},
],
},
}),
);
expect(errors.join()).toMatch(/defaultRoles\[0\]/);
});
it("refuses a duplicate name", () => {
const errors = validateManifest(
base({
contributes: {
permissions: [
{ name: "use", titleKey: "k", descriptionKey: "d" },
{ name: "use", titleKey: "k2", descriptionKey: "d2" },
],
},
}),
);
expect(errors.join()).toMatch(/declared more than once/);
});
it("requires both i18n keys", () => {
const errors = validateManifest(
base({ contributes: { permissions: [{ name: "use" }] } }),
);
expect(errors.join()).toMatch(/titleKey/);
expect(errors.join()).toMatch(/descriptionKey/);
});
// The id an admin grants is <pluginId>.<name>, so cross-field checks compare
// against the qualified form rather than the short name.
it("matches a service permission against the qualified id", () => {
const { errors } = parseManifest(
base({
provides: [
{
service: "sample.thing",
version: "1.0.0",
permission: "sample-plugin.use",
},
}),
),
).toEqual([]);
],
contributes: {
permissions: [{ name: "use", titleKey: "k", descriptionKey: "d" }],
},
}),
);
expect(errors).toEqual([]);
});
});
@@ -279,10 +385,13 @@ describe("services, secrets and actions", () => {
},
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
permissions: [
{
name: "use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
},
}),
);
@@ -297,10 +406,13 @@ describe("services, secrets and actions", () => {
{ key: "api-key", permission: "sample-plugin.missing" },
],
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
permissions: [
{
name: "use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
},
}),
);
@@ -320,10 +432,13 @@ describe("services, secrets and actions", () => {
const { errors } = parseManifest(
base({
contributes: {
permissionGroup: {
group: "sample-plugin",
permissions: ["sample-plugin.use"],
},
permissions: [
{
name: "use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
actions: [
{
id: "sample.open",
@@ -0,0 +1,507 @@
/**
* A5: plugin permissions in core RBAC.
*
* Covers the four things that were structurally wrong before: a plugin could
* name any namespace it liked, a disabled plugin's permissions stopped being
* grantable, a revoked role default came back on the next boot, and the ai
* group was declared twice.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import type { Fixture } from "./fixture-plugin.js";
const state = vi.hoisted(() => ({
/** roleName -> the permission array stored on that role. */
roles: new Map<string, { id: number; permissions: string[] }>(),
/** The rbac_applied_defaults rows, as "<role>:<permission>". */
appliedDefaults: new Set<string>(),
/** The rbac_known_permissions rows. */
known: new Set<string>(),
warnings: [] as string[],
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn((message: string) => {
state.warnings.push(message);
}),
error: vi.fn(),
success: vi.fn(),
},
databaseLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
}));
vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
hasPermission: async () => true,
invalidateUserPermissionCache: vi.fn(),
}),
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentRoleRepository: () => ({
findRoleByName: async (name: string) => {
const role = state.roles.get(name);
if (!role) return null;
return {
id: role.id,
name,
permissions: JSON.stringify(role.permissions),
};
},
updateRole: async (id: number, updates: { permissions?: string }) => {
for (const [name, role] of state.roles) {
if (role.id !== id) continue;
if (updates.permissions === undefined) continue;
state.roles.set(name, {
id,
permissions: JSON.parse(updates.permissions) as string[],
});
}
},
listRoleUserIds: async () => [],
listRoles: async () =>
[...state.roles.entries()].map(([name, role]) => ({
id: role.id,
name,
permissions: JSON.stringify(role.permissions),
})),
}),
createCurrentRbacPermissionRepository: () => ({
listKnown: async () =>
[...state.known].map((permission) => ({ permission, pluginId: null })),
recordKnown: async (rows: { permission: string }[]) => {
for (const row of rows) state.known.add(row.permission);
return rows.length;
},
listAppliedDefaults: async () =>
[...state.appliedDefaults].map((key) => {
const [roleName, ...rest] = key.split(":");
return { roleName, permission: rest.join(":") };
}),
recordAppliedDefaults: async (
rows: { roleName: string; permission: string }[],
) => {
for (const row of rows) {
state.appliedDefaults.add(`${row.roleName}:${row.permission}`);
}
return rows.length;
},
}),
}));
const { PluginLoader } = await import("../../plugins/loader.js");
const { createFixturePlugin } = await import("./fixture-plugin.js");
const {
getPermissionCatalog,
isValidPermission,
markPluginPermissionsDisabled,
registerPluginPermissions,
resetPermissionCatalog,
} = await import("../../utils/permission-catalog.js");
const { parseManifest, qualifyPermission } =
await import("../../plugins/manifest.js");
const { resolvePermission, declaredPermissions } =
await import("../../plugins/rbac.js");
const { createRbacMiddleware } = await import("../../plugins/http.js");
function manifest(
id: string,
permissions: Record<string, unknown>[],
extra: Record<string, unknown> = {},
) {
return {
id,
name: id,
version: "1.0.0",
description: "A fixture.",
author: { name: "Termix" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["kv:own"],
contributes: { permissions },
...extra,
};
}
function permission(name: string, defaultRoles?: string[]) {
const entry: Record<string, unknown> = {
name,
titleKey: `permissions.${name}.title`,
descriptionKey: `permissions.${name}.description`,
};
if (defaultRoles) entry.defaultRoles = defaultRoles;
return entry;
}
beforeEach(() => {
state.roles.clear();
state.appliedDefaults.clear();
state.known.clear();
state.warnings.length = 0;
resetPermissionCatalog();
});
afterEach(() => {
resetPermissionCatalog();
});
describe("namespacing", () => {
it("registers a short name as <pluginId>.<name>", () => {
expect(qualifyPermission("ai", "services.use")).toBe("ai.services.use");
expect(qualifyPermission("ai", "use")).toBe("ai.use");
});
it("qualifies the ids a manifest declares", () => {
const { manifest: parsed, errors } = parseManifest(
manifest("ai", [permission("use"), permission("services.use")]),
);
expect(errors).toEqual([]);
expect(
parsed?.contributes?.permissions?.map((entry) =>
qualifyPermission("ai", entry.name),
),
).toEqual(["ai.use", "ai.services.use"]);
});
it("prefixes a bare short name and leaves a full id alone", () => {
const parsed = parseManifest(
manifest("ai", [permission("services.use")]),
).manifest!;
expect(resolvePermission(parsed, "services.use")).toBe("ai.services.use");
// Its own full id is already qualified.
expect(resolvePermission(parsed, "ai.services.use")).toBe(
"ai.services.use",
);
// A core group is used as given.
expect(resolvePermission(parsed, "hosts.view")).toBe("hosts.view");
});
it("leaves another plugin's id alone, for a cross-plugin check", () => {
registerPluginPermissions({
group: "automations",
pluginId: "automations",
label: "Automations",
permissions: ["automations.run"],
});
const parsed = parseManifest(manifest("ai", [permission("use")])).manifest!;
expect(resolvePermission(parsed, "automations.run")).toBe(
"automations.run",
);
});
});
describe("namespace escalation is refused", () => {
it.each([
["admin.users.manage", /reserved core group/],
["hosts.view", /reserved core group/],
["credentials.read", /reserved core group/],
["snippets.edit", /reserved core group/],
])("refuses the core name %s", (name, pattern) => {
const { errors } = parseManifest(manifest("sample", [permission(name)]));
expect(errors.join()).toMatch(pattern);
});
it("refuses a name that repeats the plugin's own id", () => {
const { errors } = parseManifest(manifest("ai", [permission("ai.use")]));
expect(errors.join()).toMatch(/already starts with this plugin/);
});
// The manifest validator cannot know which other plugins exist, so the
// runtime re-checks against the live catalog when the plugin registers.
it("drops a permission naming another plugin's namespace at registration", async () => {
registerPluginPermissions({
group: "automations",
pluginId: "automations",
label: "Automations",
permissions: ["automations.run"],
});
const fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: {
permissions: [permission("automations.run"), permission("ps")],
},
},
});
const { resetPluginRuntime, getPluginRuntime, activatePlugin } =
await import("../../plugins/index.js");
resetPluginRuntime();
const loader = getPluginRuntime().loader;
try {
const plugin = await loader.load(fixture.dir);
await activatePlugin(plugin.id);
// Its own name registered; the one reaching into automations did not.
expect(isValidPermission("docker.ps")).toBe(true);
expect(isValidPermission("docker.automations.run")).toBe(false);
expect(state.warnings.join()).toMatch(/belongs to someone else/);
} finally {
await loader.shutdown();
fixture.cleanup();
}
});
it("only lets a route require a permission the plugin declares", () => {
const parsed = parseManifest(manifest("ai", [permission("use")])).manifest!;
expect(declaredPermissions(parsed).has("ai.use")).toBe(true);
expect(() => createRbacMiddleware(parsed, "use")).not.toThrow();
expect(() => createRbacMiddleware(parsed, "admin.users.manage")).toThrow(
/cannot require permission/,
);
});
});
describe("a disabled plugin's permissions survive", () => {
beforeEach(() => {
registerPluginPermissions({
group: "ai",
pluginId: "ai",
label: "AI Assistant",
icon: "Sparkles",
permissions: ["ai.use", "ai.services.use"],
});
});
// The exact shape PUT /rbac/roles/:id validates, which used to 400 the whole
// role while the plugin was off.
it("keeps validating a role that holds them", () => {
markPluginPermissionsDisabled("ai");
const saved = ["hosts.view", "ai.use", "ai.services.use"];
const invalid = saved.filter((entry) => !isValidPermission(entry));
expect(invalid).toEqual([]);
});
it("keeps the group listed, marked disabled", () => {
markPluginPermissionsDisabled("ai");
const entry = getPermissionCatalog().find((row) => row.group === "ai");
expect(entry?.enabled).toBe(false);
expect(entry?.permissions).toContain("ai.use");
});
it("re-enables on the next registration", () => {
markPluginPermissionsDisabled("ai");
registerPluginPermissions({
group: "ai",
pluginId: "ai",
label: "AI Assistant",
permissions: ["ai.use", "ai.services.use"],
});
const entry = getPermissionCatalog().find((row) => row.group === "ai");
expect(entry?.enabled).toBe(true);
});
});
describe("the duplicate ai group is gone", () => {
it("lists ai exactly once when the plugin registers", () => {
registerPluginPermissions({
group: "ai",
pluginId: "ai",
label: "AI Assistant",
permissions: ["ai.use"],
});
const matches = getPermissionCatalog().filter(
(entry) => entry.group === "ai",
);
expect(matches).toHaveLength(1);
expect(matches[0].pluginId).toBe("ai");
});
});
describe("shipped manifests keep their permission ids", () => {
const root = path.resolve(import.meta.dirname, "../../../../plugins");
function idsFor(pluginId: string): string[] {
const raw = JSON.parse(
fs.readFileSync(path.join(root, pluginId, "manifest.json"), "utf8"),
);
const { manifest: parsed, errors } = parseManifest(raw);
expect(errors).toEqual([]);
return (parsed?.contributes?.permissions ?? []).map((entry) =>
qualifyPermission(pluginId, entry.name),
);
}
// A role holding these today must keep working, which is only true because
// <pluginId>.<name> reproduces the old id byte for byte.
it("ai", () => {
expect(idsFor("ai")).toEqual([
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
"ai.services.use",
"ai.secrets.share",
]);
});
it("automations", () => {
expect(idsFor("automations")).toEqual([
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
]);
});
it("tailscale", () => {
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
});
it("a role holding the old ids still validates once registered", () => {
for (const pluginId of ["ai", "automations", "tailscale"]) {
const ids = idsFor(pluginId);
registerPluginPermissions({
group: pluginId,
pluginId,
label: pluginId,
permissions: ids,
});
}
const stored = [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
"automations.run",
"automations.view",
"tailscale.devices.view",
];
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
});
});
describe("role defaults apply exactly once", () => {
let fixture: Fixture;
let loader: InstanceType<typeof PluginLoader>;
beforeEach(() => {
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
state.roles.set("admin", { id: 1, permissions: ["*"] });
});
afterEach(async () => {
await loader?.shutdown();
fixture?.cleanup();
});
async function registerOnce() {
const { resetPluginRuntime, getPluginRuntime } =
await import("../../plugins/index.js");
resetPluginRuntime();
loader = getPluginRuntime().loader;
const plugin = await loader.load(fixture.dir);
const { activatePlugin } = await import("../../plugins/index.js");
await activatePlugin(plugin.id);
}
it("adds a declared default to the named role", async () => {
fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: { permissions: [permission("run", ["user"])] },
},
});
await registerOnce();
expect(state.roles.get("user")?.permissions).toContain("docker.run");
expect(state.appliedDefaults.has("user:docker.run")).toBe(true);
});
// The whole point of the ledger: an admin's revoke is a decision, not a gap.
it("does not re-add a default an admin removed", async () => {
fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: { permissions: [permission("run", ["user"])] },
},
});
await registerOnce();
expect(state.roles.get("user")?.permissions).toContain("docker.run");
// The admin revokes it.
state.roles.set("user", { id: 2, permissions: ["hosts.*"] });
await loader.shutdown();
await registerOnce();
expect(state.roles.get("user")?.permissions).not.toContain("docker.run");
});
it("leaves a role alone when the plugin declares no defaults", async () => {
fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: { permissions: [permission("run")] },
},
});
await registerOnce();
expect(state.roles.get("user")?.permissions).toEqual(["hosts.*"]);
expect(isValidPermission("docker.run")).toBe(true);
});
it("records the qualified id, not the short name", async () => {
fixture = createFixturePlugin({
id: "docker",
capabilities: ["kv:own"],
manifestOverrides: {
category: "Infrastructure",
contributes: { permissions: [permission("containers.run", ["user"])] },
},
});
await registerOnce();
expect(state.roles.get("user")?.permissions).toContain(
"docker.containers.run",
);
expect(state.known.has("docker.containers.run")).toBe(true);
});
});
@@ -63,7 +63,8 @@ const { createFixturePlugin } = await import("./fixture-plugin.js");
const PROVIDER_ID = "ssh-terminal";
const CONSUMER_ID = "docker";
const KEY = "api-key";
const PERMISSION = "testplugin.secrets.share";
// Registered as <pluginId>.<name>, so it carries the provider fixture id.
const PERMISSION = "ssh-terminal.secrets.share";
const SECRET = "sk-fixture-value";
/**
@@ -108,7 +109,13 @@ function providerFixture(): Fixture {
category: "Terminal",
providesSecret: [{ key: KEY, permission: PERMISSION }],
contributes: {
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
permissions: [
{
name: "secrets.share",
titleKey: "permissions.secrets.share.title",
descriptionKey: "permissions.secrets.share.description",
},
],
},
},
});
@@ -292,7 +299,13 @@ export async function activate(ctx) {
category: "Terminal",
providesSecret: [{ key: KEY, permission: PERMISSION }],
contributes: {
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
permissions: [
{
name: "secrets.share",
titleKey: "permissions.secrets.share.title",
descriptionKey: "permissions.secrets.share.description",
},
],
},
},
});
@@ -58,7 +58,8 @@ const { pluginEvents } = await import("../../plugins/events.js");
const { createFixturePlugin } = await import("./fixture-plugin.js");
const SERVICE = "testplugin.greet";
const PERMISSION = "testplugin.greet.use";
// Registered as <pluginId>.<name>, so it carries the provider fixture id.
const PERMISSION = "ssh-terminal.greet.use";
/**
* The provider publishes a greet service and records every delegated call to
@@ -101,7 +102,13 @@ function providerFixture(): Fixture {
{ service: SERVICE, version: "1.2.0", permission: PERMISSION },
],
contributes: {
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
permissions: [
{
name: "greet.use",
titleKey: "permissions.greet.use.title",
descriptionKey: "permissions.greet.use.description",
},
],
},
},
});
@@ -3,11 +3,37 @@ import {
PERMISSION_CATALOG,
getPermissionCatalog,
isValidPermission,
registerPermissionGroup,
unregisterPermissionGroup,
loadKnownPermissions,
markPluginPermissionsDisabled,
registerPluginPermissions,
rememberPermissionGroup,
resetPermissionCatalog,
SYSTEM_ROLE_DEFAULTS,
} from "../../utils/permission-catalog.js";
const GROUP = "testplugin";
const PERMISSIONS = ["testplugin.view", "testplugin.manage"];
function register() {
registerPluginPermissions({
group: GROUP,
pluginId: GROUP,
label: "Test Plugin",
icon: "Sparkles",
permissions: PERMISSIONS,
items: PERMISSIONS.map((permission) => ({
permission,
titleKey: "permissions.x.title",
descriptionKey: "permissions.x.description",
})),
});
}
describe("permission catalog", () => {
afterEach(() => {
resetPermissionCatalog();
});
it("accepts every cataloged permission and group wildcard", () => {
for (const entry of PERMISSION_CATALOG) {
expect(isValidPermission(`${entry.group}.*`)).toBe(true);
@@ -27,74 +53,126 @@ describe("permission catalog", () => {
expect(isValidPermission("")).toBe(false);
expect(isValidPermission("hosts")).toBe(false);
});
});
describe("runtime permission group lifecycle", () => {
const group = "testplugin";
const permissions = ["testplugin.view", "testplugin.manage"];
afterEach(() => {
unregisterPermissionGroup(group);
// Both moved out to their plugins in A5; the ids did not change, so no role
// had to be migrated.
it("no longer carries the ai or automations groups", () => {
const groups = PERMISSION_CATALOG.map((entry) => entry.group);
expect(groups).not.toContain("ai");
expect(groups).not.toContain("automations");
});
it("rejects a group's permissions before it is registered", () => {
it("no longer seeds ai or automations onto the user role", () => {
expect(SYSTEM_ROLE_DEFAULTS.user.permissions).not.toContain("ai.*");
expect(SYSTEM_ROLE_DEFAULTS.user.permissions).not.toContain(
"automations.*",
);
expect(SYSTEM_ROLE_DEFAULTS.user.permissions).toContain("hosts.*");
});
it("gives every core group a label key for the role editor", () => {
for (const entry of PERMISSION_CATALOG) {
expect(entry.labelKey).toBeTruthy();
}
});
});
describe("plugin permission lifecycle", () => {
afterEach(() => {
resetPermissionCatalog();
});
it("rejects a plugin's permissions before it registers", () => {
expect(isValidPermission("testplugin.view")).toBe(false);
expect(isValidPermission("testplugin.*")).toBe(false);
});
it("accepts a group's permissions and wildcard once registered", () => {
registerPermissionGroup({ group, permissions });
it("accepts them once registered", () => {
register();
expect(isValidPermission("testplugin.view")).toBe(true);
expect(isValidPermission("testplugin.manage")).toBe(true);
expect(isValidPermission("testplugin.*")).toBe(true);
expect(isValidPermission("*")).toBe(true);
});
it("rejects a group's permissions again after it is unregistered", () => {
registerPermissionGroup({ group, permissions });
it("carries the plugin id, label and icon into the catalog", () => {
register();
const entry = getPermissionCatalog().find((row) => row.group === GROUP);
expect(entry?.pluginId).toBe(GROUP);
expect(entry?.label).toBe("Test Plugin");
expect(entry?.icon).toBe("Sparkles");
expect(entry?.enabled).toBe(true);
expect(entry?.items).toHaveLength(2);
});
// The bug A5 fixes: unregistering made PUT /rbac/roles/:id reject every role
// that still held one of these, so an admin could not save an unrelated
// change while the plugin was off.
it("keeps a disabled plugin's permissions valid", () => {
register();
markPluginPermissionsDisabled(GROUP);
expect(isValidPermission("testplugin.view")).toBe(true);
unregisterPermissionGroup(group);
expect(isValidPermission("testplugin.view")).toBe(false);
expect(isValidPermission("testplugin.*")).toBe(false);
expect(isValidPermission("testplugin.*")).toBe(true);
});
it("includes registered groups in getPermissionCatalog and drops them on unregister", () => {
expect(getPermissionCatalog().some((entry) => entry.group === group)).toBe(
false,
);
it("marks a disabled plugin's group so the editor can grey it", () => {
register();
markPluginPermissionsDisabled(GROUP);
registerPermissionGroup({ group, permissions });
expect(getPermissionCatalog().some((entry) => entry.group === group)).toBe(
true,
);
unregisterPermissionGroup(group);
expect(getPermissionCatalog().some((entry) => entry.group === group)).toBe(
false,
);
const entry = getPermissionCatalog().find((row) => row.group === GROUP);
expect(entry?.enabled).toBe(false);
});
it("does not mutate the static PERMISSION_CATALOG array", () => {
const originalLength = PERMISSION_CATALOG.length;
registerPermissionGroup({ group, permissions });
register();
expect(PERMISSION_CATALOG.length).toBe(originalLength);
expect(PERMISSION_CATALOG.some((entry) => entry.group === group)).toBe(
expect(PERMISSION_CATALOG.some((entry) => entry.group === GROUP)).toBe(
false,
);
});
it("overwrites an existing runtime group when re-registered with the same name", () => {
registerPermissionGroup({ group, permissions: ["testplugin.view"] });
expect(isValidPermission("testplugin.manage")).toBe(false);
it("lists a plugin group exactly once when re-registered", () => {
register();
register();
registerPermissionGroup({
group,
permissions: ["testplugin.view", "testplugin.manage"],
});
expect(isValidPermission("testplugin.manage")).toBe(true);
const matches = getPermissionCatalog().filter(
(entry) => entry.group === GROUP,
);
expect(matches).toHaveLength(1);
});
});
describe("known permissions", () => {
afterEach(() => {
resetPermissionCatalog();
});
// The upgrade path: a permission a role already holds stays grantable even
// if no plugin ever registers it again.
it("accepts a permission loaded from the record with no plugin present", () => {
expect(isValidPermission("gone.use")).toBe(false);
loadKnownPermissions(["gone.use"]);
expect(isValidPermission("gone.use")).toBe(true);
expect(isValidPermission("gone.*")).toBe(true);
});
it("accepts a group recorded from a wildcard nothing else named", () => {
rememberPermissionGroup("legacy");
expect(isValidPermission("legacy.*")).toBe(true);
// Still rejects a concrete permission nobody ever registered.
expect(isValidPermission("legacy.invent")).toBe(false);
});
it("still rejects a permission nobody has ever registered", () => {
loadKnownPermissions(["gone.use"]);
expect(isValidPermission("madeup.use")).toBe(false);
});
});
@@ -120,11 +120,13 @@ describe("PermissionManager.hasPermission wildcard matching", () => {
});
it("resolves wildcards for a runtime-registered permission group", async () => {
const { registerPermissionGroup, unregisterPermissionGroup } =
const { registerPluginPermissions, resetPermissionCatalog } =
await import("../../utils/permission-catalog.js");
registerPermissionGroup({
registerPluginPermissions({
group: "testplugin",
pluginId: "testplugin",
label: "Test Plugin",
permissions: ["testplugin.view", "testplugin.manage"],
});
@@ -136,7 +138,7 @@ describe("PermissionManager.hasPermission wildcard matching", () => {
withPermissions(["*"]);
expect(await manager.hasPermission("u1", "testplugin.view")).toBe(true);
} finally {
unregisterPermissionGroup("testplugin");
resetPermissionCatalog();
}
});
});