mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: namespace plugin permissions and keep them valid when a plugin is off
This commit is contained in:
1 parent
8431570ff7
commit
cc15559ea1
53 files changed
+31442
-351
No files matched your search
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user