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

+16
View File
@@ -751,6 +751,22 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS rbac_known_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
permission TEXT NOT NULL,
plugin_id TEXT,
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (permission)
);
CREATE TABLE IF NOT EXISTS rbac_applied_defaults (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role_name TEXT NOT NULL,
permission TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (role_name, permission)
);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
+54
View File
@@ -2282,3 +2282,57 @@ export const pluginMigrations = mysqlTable(
);
// --- plugins end ---
// --- rbac plugin permissions begin ---
/**
* Every role permission core has ever registered.
*
* Deliberately has no foreign key to `plugins`: the whole point is that a role
* keeps working when the plugin that contributed a permission is disabled or
* uninstalled. Without this, unregistering a group made PUT /rbac/roles/:id
* reject the entire role.
*/
export const rbacKnownPermissions = mysqlTable(
"rbac_known_permissions",
{
id: int("id").autoincrement().primaryKey(),
permission: varchar("permission", { length: 255 }).notNull(),
/** Which plugin contributed it, or null for a core permission. */
pluginId: varchar("plugin_id", { length: 255 }),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_rbac_known_permissions_permission").on(table.permission),
],
);
/**
* Which plugin role defaults have already been applied.
*
* Applying a default is a one-time suggestion, so an admin who revokes it does
* not get it handed back on the next restart. This lived in `plugin_storage`,
* which cascades with the plugin, so uninstall-then-reinstall silently re-added
* a permission that had been deliberately removed.
*/
export const rbacAppliedDefaults = mysqlTable(
"rbac_applied_defaults",
{
id: int("id").autoincrement().primaryKey(),
roleName: varchar("role_name", { length: 255 }).notNull(),
permission: varchar("permission", { length: 255 }).notNull(),
appliedAt: text("applied_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_rbac_applied_defaults_role_permission").on(
table.roleName,
table.permission,
),
],
);
// --- rbac plugin permissions end ---
+54
View File
@@ -2283,3 +2283,57 @@ export const pluginMigrations = pgTable(
);
// --- plugins end ---
// --- rbac plugin permissions begin ---
/**
* Every role permission core has ever registered.
*
* Deliberately has no foreign key to `plugins`: the whole point is that a role
* keeps working when the plugin that contributed a permission is disabled or
* uninstalled. Without this, unregistering a group made PUT /rbac/roles/:id
* reject the entire role.
*/
export const rbacKnownPermissions = pgTable(
"rbac_known_permissions",
{
id: serial("id").primaryKey(),
permission: varchar("permission", { length: 255 }).notNull(),
/** Which plugin contributed it, or null for a core permission. */
pluginId: varchar("plugin_id", { length: 255 }),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_rbac_known_permissions_permission").on(table.permission),
],
);
/**
* Which plugin role defaults have already been applied.
*
* Applying a default is a one-time suggestion, so an admin who revokes it does
* not get it handed back on the next restart. This lived in `plugin_storage`,
* which cascades with the plugin, so uninstall-then-reinstall silently re-added
* a permission that had been deliberately removed.
*/
export const rbacAppliedDefaults = pgTable(
"rbac_applied_defaults",
{
id: serial("id").primaryKey(),
roleName: varchar("role_name", { length: 255 }).notNull(),
permission: varchar("permission", { length: 255 }).notNull(),
appliedAt: text("applied_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_rbac_applied_defaults_role_permission").on(
table.roleName,
table.permission,
),
],
);
// --- rbac plugin permissions end ---
+54
View File
@@ -2281,3 +2281,57 @@ export const pluginMigrations = sqliteTable(
);
// --- plugins end ---
// --- rbac plugin permissions begin ---
/**
* Every role permission core has ever registered.
*
* Deliberately has no foreign key to `plugins`: the whole point is that a role
* keeps working when the plugin that contributed a permission is disabled or
* uninstalled. Without this, unregistering a group made PUT /rbac/roles/:id
* reject the entire role.
*/
export const rbacKnownPermissions = sqliteTable(
"rbac_known_permissions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
permission: text("permission").notNull(),
/** Which plugin contributed it, or null for a core permission. */
pluginId: text("plugin_id"),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_rbac_known_permissions_permission").on(table.permission),
],
);
/**
* Which plugin role defaults have already been applied.
*
* Applying a default is a one-time suggestion, so an admin who revokes it does
* not get it handed back on the next restart. This lived in `plugin_storage`,
* which cascades with the plugin, so uninstall-then-reinstall silently re-added
* a permission that had been deliberately removed.
*/
export const rbacAppliedDefaults = sqliteTable(
"rbac_applied_defaults",
{
id: integer("id").primaryKey({ autoIncrement: true }),
roleName: text("role_name").notNull(),
permission: text("permission").notNull(),
appliedAt: text("applied_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_rbac_applied_defaults_role_permission").on(
table.roleName,
table.permission,
),
],
);
// --- rbac plugin permissions end ---
@@ -39,6 +39,7 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos
import { PluginRegistryRepository } from "./plugin-registry-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
@@ -401,6 +402,13 @@ export function createCurrentPluginMigrationRepository(): PluginMigrationReposit
);
}
export function createCurrentRbacPermissionRepository(): RbacPermissionRepository {
return new RbacPermissionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("rbac_permission_repository_write"),
);
}
export function createCurrentPluginRegistryRepository(): PluginRegistryRepository {
return new PluginRegistryRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,112 @@
import { rbacKnownPermissions, rbacAppliedDefaults } from "../db/schema.js";
import { insertReturning } from "./returning.js";
import type { DatabaseContext } from "./database-context.js";
export type RbacKnownPermissionRecord =
typeof rbacKnownPermissions.$inferSelect;
export type RbacAppliedDefaultRecord = typeof rbacAppliedDefaults.$inferSelect;
export interface KnownPermissionInput {
permission: string;
pluginId?: string | null;
}
export interface AppliedDefaultInput {
roleName: string;
permission: string;
}
/**
* The two records that make plugin permissions outlive their plugin.
*
* `rbac_known_permissions` is why a role holding `ai.use` still saves while the
* ai plugin is disabled. `rbac_applied_defaults` is why a default an admin
* revoked is not handed back on the next boot. Neither has a foreign key to
* `plugins`, because both have to survive the plugin going away.
*/
export class RbacPermissionRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async listKnown(): Promise<RbacKnownPermissionRecord[]> {
return this.context.drizzle.select().from(rbacKnownPermissions);
}
/** Inserts the ones that are new. Re-recording an existing one is a no-op. */
async recordKnown(
entries: KnownPermissionInput[],
now = new Date().toISOString(),
): Promise<number> {
if (entries.length === 0) return 0;
const existing = new Set(
(await this.listKnown()).map((row) => row.permission),
);
let written = 0;
for (const entry of entries) {
if (existing.has(entry.permission)) continue;
existing.add(entry.permission);
// Another boot can win the race on a shared database, and losing it just
// means the row is already there.
try {
await insertReturning(this.context, rbacKnownPermissions, {
permission: entry.permission,
pluginId: entry.pluginId ?? null,
firstSeenAt: now,
});
written += 1;
} catch {
continue;
}
}
if (written > 0) await this.afterWrite();
return written;
}
async listAppliedDefaults(): Promise<RbacAppliedDefaultRecord[]> {
return this.context.drizzle.select().from(rbacAppliedDefaults);
}
async recordAppliedDefaults(
entries: AppliedDefaultInput[],
now = new Date().toISOString(),
): Promise<number> {
if (entries.length === 0) return 0;
const existing = new Set(
(await this.listAppliedDefaults()).map(
(row) => `${row.roleName}:${row.permission}`,
),
);
let written = 0;
for (const entry of entries) {
const key = `${entry.roleName}:${entry.permission}`;
if (existing.has(key)) continue;
existing.add(key);
try {
await insertReturning(this.context, rbacAppliedDefaults, {
roleName: entry.roleName,
permission: entry.permission,
appliedAt: now,
});
written += 1;
} catch {
continue;
}
}
if (written > 0) await this.afterWrite();
return written;
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
+15
View File
@@ -35,6 +35,7 @@ import {
unregisterPluginHttp,
} from "./http.js";
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
import { resolvePermission } from "./rbac.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
PluginCapabilityError,
@@ -474,6 +475,20 @@ export function createPluginContext(
},
rbac: {
// A short name takes this plugin's prefix; another plugin's id or a core
// group is used as given, which is what makes a cross-plugin check
// expressible without letting a plugin gate its own routes on it.
has: async (permission) => {
const actor = getActor();
if (!actor) return false;
return checkPermission(actor, resolvePermission(manifest, permission));
},
hasFor: async (userId, permission) => {
if (typeof userId !== "string" || userId.length === 0) return false;
return checkPermission(userId, resolvePermission(manifest, permission));
},
require: (permission) => createRbacMiddleware(manifest, permission),
},
+11 -8
View File
@@ -30,6 +30,7 @@ import { pluginLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { runAsActor } from "./actor.js";
import { hasCapability } from "./permissions.js";
import { declaredPermissions, resolvePermission } from "./rbac.js";
/** Matches what core's own routes accept, so moving a route changes nothing. */
const DEFAULT_BODY_LIMIT = "2mb";
@@ -272,18 +273,20 @@ async function writePublicRouteAudit(
* route could require admin.users.manage and borrow someone else's authority,
* which is the escalation shape the manifest validator already blocks for
* role defaults.
*
* The 401/403 bodies match requirePermission(), so a plugin route denies the
* same way a core route does and a client needs one code path.
*/
export function createRbacMiddleware(
manifest: PluginManifest,
permission: string,
): PluginMiddleware {
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
);
const required = resolvePermission(manifest, permission);
const declared = declaredPermissions(manifest);
if (!declared.has(permission)) {
if (!declared.has(required)) {
throw new Error(
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissionGroup.permissions`,
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissions`,
);
}
@@ -291,7 +294,7 @@ export function createRbacMiddleware(
void (async () => {
const userId = (req as Request & { userId?: string }).userId;
if (!userId) {
res.status(401).json({ error: "Authentication required" });
res.status(401).json({ error: "Not authenticated" });
return;
}
@@ -300,10 +303,10 @@ export function createRbacMiddleware(
await import("../utils/permission-manager.js");
const allowed = await PermissionManager.getInstance().hasPermission(
userId,
permission,
required,
);
if (!allowed) {
res.status(403).json({ error: "Permission denied", permission });
res.status(403).json({ error: "Insufficient permissions", required });
return;
}
next();
+122 -58
View File
@@ -12,6 +12,7 @@
import { pluginLogger } from "../utils/logger.js";
import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
let loader: PluginLoader | null = null;
@@ -187,26 +188,91 @@ async function persistRuntimeState(loaded: LoadedPlugin[]): Promise<void> {
/**
* Puts a plugin's declared permissions into the role catalog.
*
* Until this runs a plugin permission cannot be granted at all, because
* PUT /rbac/roles/:id rejects any string isValidPermission does not know.
* Registering here is what makes one appear in the admin role editor exactly
* like hosts.view does.
* Each name is registered as `<pluginId>.<name>`, so a plugin cannot claim a
* core group or another plugin's namespace. Until this runs a plugin permission
* cannot be granted at all, because PUT /rbac/roles/:id rejects any string
* isValidPermission does not know.
*
* The qualified ids are also written to rbac_known_permissions, which is what
* keeps a role holding one valid after the plugin is disabled or removed.
*/
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
const group = plugin.manifest.contributes?.permissionGroup;
if (!group) return;
const declared = plugin.manifest.contributes?.permissions;
if (!declared || declared.length === 0) return;
try {
const { registerPermissionGroup } =
await import("../utils/permission-catalog.js");
registerPermissionGroup({
group: group.group,
permissions: group.permissions,
const {
registerPluginPermissions: register,
PERMISSION_CATALOG,
getPermissionCatalog,
rememberPermissions,
} = await import("../utils/permission-catalog.js");
const { qualifyPermission, RESERVED_PERMISSION_PREFIXES } =
await import("./manifest.js");
// The manifest validator blocks core groups and the plugin's own id, but
// it cannot know which other plugins exist. Re-checked here because the
// stored manifest is data.
const coreGroups = new Set<string>([
...RESERVED_PERMISSION_PREFIXES,
...PERMISSION_CATALOG.map((entry) => entry.group),
]);
// Both the loaded plugins and any namespace already in the catalog: a
// disabled plugin still owns its permissions, so claiming them while it is
// off would let the namespace change hands.
const otherPluginIds = new Set(
[
...getPluginRuntime()
.loader.list()
.map((candidate) => candidate.id),
...getPermissionCatalog()
.filter((entry) => entry.pluginId !== undefined)
.map((entry) => entry.group),
].filter((id) => id !== plugin.id),
);
const accepted = declared.filter((permission) => {
const head = permission.name.split(".")[0];
if (coreGroups.has(head) || otherPluginIds.has(head)) {
pluginLogger.warn(
`Ignoring ${plugin.id} permission "${permission.name}": it starts with "${head}", which belongs to someone else`,
{ operation: "plugin_permissions" },
);
return false;
}
return true;
});
if (group.defaultForRole) {
await applyRoleDefaults(plugin.id, group);
}
if (accepted.length === 0) return;
const items = accepted.map((permission) => ({
permission: qualifyPermission(plugin.id, permission.name),
titleKey: permission.titleKey,
descriptionKey: permission.descriptionKey,
}));
register({
group: plugin.id,
pluginId: plugin.id,
label: plugin.manifest.name,
icon: plugin.manifest.icon,
enabled: true,
permissions: items.map((item) => item.permission),
items,
});
rememberPermissions(items.map((item) => item.permission));
const { createCurrentRbacPermissionRepository } =
await import("../database/repositories/factory.js");
await createCurrentRbacPermissionRepository().recordKnown(
items.map((item) => ({
permission: item.permission,
pluginId: plugin.id,
})),
);
await applyRoleDefaults(plugin.id, accepted);
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${plugin.id}`,
@@ -219,44 +285,49 @@ async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
/**
* Applies a plugin's declared role suggestion, once.
*
* Only permissions the plugin declares itself are eligible; the manifest
* validator enforces that, and this re-checks it because the stored manifest
* is data. Without it a manifest could add admin.* to any role at boot.
*
* Each default is applied at most once and the fact is recorded, so an admin
* who later revokes it does not get it handed back on the next restart. The
* old code claimed that behaviour in a comment but re-added the permission
* every time.
* Only the seeded system roles are eligible, and only permissions the plugin
* declares itself. Each default is applied at most once and the fact is
* recorded in rbac_applied_defaults, so an admin who later revokes it does not
* get it handed back on the next restart. That ledger lives in core rather than
* in plugin_storage, which cascades with the plugin: uninstall-then-reinstall
* used to silently re-add a permission that had been deliberately removed.
*/
async function applyRoleDefaults(
pluginId: string,
group: {
group: string;
permissions: string[];
defaultForRole?: Record<string, string[]>;
},
declared: PluginPermissionContribution[],
): Promise<void> {
const { createCurrentRoleRepository, createCurrentPluginStorageRepository } =
const wanted = declared.filter(
(permission) => (permission.defaultRoles ?? []).length > 0,
);
if (wanted.length === 0) return;
const { createCurrentRoleRepository, createCurrentRbacPermissionRepository } =
await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const { qualifyPermission, SYSTEM_ROLE_NAMES } =
await import("./manifest.js");
const roleRepository = createCurrentRoleRepository();
const storage = createCurrentPluginStorageRepository();
const rbacRepository = createCurrentRbacPermissionRepository();
const APPLIED_KEY = "__role_defaults_applied";
let applied: string[] = [];
try {
const raw = await storage.get(pluginId, APPLIED_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) applied = parsed;
} catch {
applied = [];
const alreadyApplied = new Set(
(await rbacRepository.listAppliedDefaults()).map(
(row) => `${row.roleName}:${row.permission}`,
),
);
const byRole = new Map<string, string[]>();
for (const permission of wanted) {
for (const role of permission.defaultRoles ?? []) {
if (!(SYSTEM_ROLE_NAMES as readonly string[]).includes(role)) continue;
const id = qualifyPermission(pluginId, permission.name);
byRole.set(role, [...(byRole.get(role) ?? []), id]);
}
}
const declared = new Set(group.permissions);
const appliedSet = new Set(applied);
const recorded: { roleName: string; permission: string }[] = [];
for (const [roleName, wanted] of Object.entries(group.defaultForRole ?? {})) {
for (const [roleName, permissions] of byRole) {
try {
const role = await roleRepository.findRoleByName(roleName);
if (!role) continue;
@@ -269,24 +340,15 @@ async function applyRoleDefaults(
}
if (!Array.isArray(current)) continue;
const missing = wanted.filter((permission) => {
if (!declared.has(permission)) {
pluginLogger.warn(
`Ignoring ${pluginId} role default "${permission}" for ${roleName}: the plugin does not declare it`,
{ operation: "plugin_permissions" },
);
return false;
}
const missing = permissions.filter((permission) => {
// Applied before means the admin has had the chance to remove it, so
// its absence now is a decision rather than a gap.
if (appliedSet.has(`${roleName}:${permission}`)) return false;
if (alreadyApplied.has(`${roleName}:${permission}`)) return false;
return !coveredBy(current as string[], permission);
});
for (const permission of wanted) {
if (declared.has(permission)) {
appliedSet.add(`${roleName}:${permission}`);
}
for (const permission of permissions) {
recorded.push({ roleName, permission });
}
if (missing.length === 0) continue;
@@ -309,7 +371,7 @@ async function applyRoleDefaults(
}
try {
await storage.set(pluginId, APPLIED_KEY, JSON.stringify([...appliedSet]));
await rbacRepository.recordAppliedDefaults(recorded);
} catch {
// Losing the record only means a default may be re-offered once.
}
@@ -344,11 +406,13 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
await pluginLoader.deactivate(pluginId);
unregisterPluginHttp(pluginId);
const group = plugin?.manifest.contributes?.permissionGroup;
if (group) {
const { unregisterPermissionGroup } =
// The group stays in the catalog, greyed: a role holding one of these is not
// wrong just because the plugin is off, and removing them made every role
// that held one unsaveable.
if (plugin?.manifest.contributes?.permissions?.length) {
const { markPluginPermissionsDisabled } =
await import("../utils/permission-catalog.js");
unregisterPermissionGroup(group.group);
markPluginPermissionsDisabled(pluginId);
}
}
+5 -1
View File
@@ -17,6 +17,9 @@ export {
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
} from "@termix/plugin-sdk/manifest";
export type {
@@ -27,7 +30,8 @@ export type {
PluginTabContribution,
PluginActionContribution,
PluginActionSlot,
PluginPermissionGroup,
PluginPermissionContribution,
SystemRoleName,
PluginServiceProvide,
PluginServiceRequire,
PluginSecretProvide,
+54
View File
@@ -0,0 +1,54 @@
/**
* How a plugin names a core role permission.
*
* A plugin writes its own permissions short ("services.use") and core registers
* them as `<pluginId>.<name>`. A cross-plugin check still has to be
* expressible, so a string that already names another plugin or a core group is
* taken as given. Everything ctx.rbac exposes, and the per-route gate in
* http.ts, resolves through here so the four paths cannot disagree.
*/
import {
PERMISSION_CATALOG,
getPermissionCatalog,
} from "../utils/permission-catalog.js";
import type { PluginManifest } from "./manifest.js";
function coreGroups(): Set<string> {
return new Set(PERMISSION_CATALOG.map((entry) => entry.group));
}
/** The ids this plugin declares, in their registered `<pluginId>.<name>` form. */
export function declaredPermissions(manifest: PluginManifest): Set<string> {
return new Set(
(manifest.contributes?.permissions ?? []).map(
(permission) => `${manifest.id}.${permission.name}`,
),
);
}
/**
* Turns whatever a plugin passed into the id core stores.
*
* A bare name takes this plugin's prefix. A string whose first segment is a
* core group, or this plugin's own id, is already a full id.
*/
export function resolvePermission(
manifest: PluginManifest,
permission: string,
): string {
const head = permission.split(".")[0];
if (!head) return permission;
if (head === manifest.id) return permission;
if (coreGroups().has(head)) return permission;
// Another plugin's registered group, which is the cross-plugin check.
const otherPlugin = getPermissionCatalog().some(
(entry) => entry.pluginId !== undefined && entry.group === head,
);
if (otherPlugin) return permission;
// Nothing claims that first segment, so it is a short name of our own.
return `${manifest.id}.${permission}`;
}
+7
View File
@@ -291,6 +291,13 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { serverReady } = await import("./database/database.js");
await serverReady;
// Before any role is edited: a role may hold a plugin permission whose
// plugin is disabled or gone, and PUT /rbac/roles/:id has to keep
// accepting it.
const { primeKnownPermissions } =
await import("./utils/known-permissions.js");
await primeKnownPermissions();
// Terminal, docker and host-metrics are deliberately absent: the
// ssh-terminal, docker and host-metrics plugins start their own servers,
// so disabling any of them stops its WS/HTTP server. See
+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();
}
});
});
+86
View File
@@ -0,0 +1,86 @@
import {
createCurrentRbacPermissionRepository,
createCurrentRoleRepository,
} from "../database/repositories/factory.js";
import {
loadKnownPermissions,
rememberPermissionGroup,
isValidPermission,
} from "./permission-catalog.js";
import { databaseLogger } from "./logger.js";
function isConcretePermission(permission: string): boolean {
return permission !== "*" && !permission.endsWith(".*");
}
/**
* Loads every permission core has ever registered, and backfills the record
* from what roles already hold.
*
* The backfill is what makes the upgrade lossless. Before A5 a plugin
* permission existed only in memory while its plugin was active, so an install
* upgrading with the ai plugin disabled would have had `ai.use` rejected on the
* next role save. Seeding from the roles themselves means anything an admin was
* already relying on stays valid, whether or not the plugin ever loads again.
*/
export async function primeKnownPermissions(): Promise<void> {
try {
const rbacRepository = createCurrentRbacPermissionRepository();
const stored = await rbacRepository.listKnown();
for (const row of stored) {
if (isConcretePermission(row.permission)) {
loadKnownPermissions([row.permission]);
} else {
// A stored "<group>.*" row records a group nothing concrete named.
rememberPermissionGroup(row.permission.slice(0, -2));
}
}
const fromRoles = new Set<string>();
for (const role of await createCurrentRoleRepository().listRoles()) {
if (!role.permissions) continue;
let parsed: unknown;
try {
parsed = JSON.parse(role.permissions);
} catch {
continue;
}
if (!Array.isArray(parsed)) continue;
for (const permission of parsed) {
if (typeof permission !== "string") continue;
// Already known, from the static catalog or a previous boot.
if (isValidPermission(permission)) continue;
// A role holding only "ai.*" still has to keep saving, so the group is
// remembered even though the wildcard itself is not a stored id.
if (!isConcretePermission(permission)) {
rememberPermissionGroup(permission.slice(0, -2));
}
// Wildcards are stored too, so the group survives the next restart.
fromRoles.add(permission);
}
}
if (fromRoles.size === 0) return;
loadKnownPermissions(
[...fromRoles].filter((permission) => isConcretePermission(permission)),
);
await rbacRepository.recordKnown(
[...fromRoles].map((permission) => ({ permission })),
);
databaseLogger.info(
`Recorded ${fromRoles.size} permission(s) already held by a role`,
{ operation: "prime_known_permissions" },
);
} catch (error) {
// A failure here only means a plugin permission may be rejected on a role
// save until its plugin registers again. It must never stop the boot.
databaseLogger.warn("Could not load known permissions", {
operation: "prime_known_permissions",
error,
});
}
}
+98 -34
View File
@@ -1,14 +1,31 @@
// Single source of truth for role permission strings. The admin role editor
// renders this catalog and PUT /rbac/roles/:id validates against it;
// PermissionManager.hasPermission resolves wildcards ("*", "<group>.*").
export interface PermissionCatalogItem {
permission: string;
/** Plugin-relative i18n keys; the frontend resolves them per plugin. */
titleKey: string;
descriptionKey: string;
}
export interface PermissionCatalogEntry {
group: string;
permissions: string[];
/** Set for a group a plugin contributed. */
pluginId?: string;
/** Core groups carry an i18n key, plugin groups their display name. */
labelKey?: string;
label?: string;
icon?: string;
/** False once the owning plugin is disabled or gone. */
enabled?: boolean;
items?: PermissionCatalogItem[];
}
export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
{
group: "hosts",
labelKey: "admin.rolePermissions.groups.hosts",
permissions: [
"hosts.view",
"hosts.create",
@@ -19,6 +36,7 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
},
{
group: "snippets",
labelKey: "admin.rolePermissions.groups.snippets",
permissions: [
"snippets.view",
"snippets.create",
@@ -27,18 +45,9 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"snippets.share",
],
},
{
group: "automations",
permissions: [
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
],
},
{
group: "credentials",
labelKey: "admin.rolePermissions.groups.credentials",
permissions: [
"credentials.view",
"credentials.create",
@@ -47,12 +56,9 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
"credentials.share",
],
},
{
group: "ai",
permissions: ["ai.use", "ai.manage_providers", "ai.apply_proposals"],
},
{
group: "admin",
labelKey: "admin.rolePermissions.groups.admin",
permissions: [
"admin.users.view",
"admin.users.manage",
@@ -64,18 +70,36 @@ export const PERMISSION_CATALOG: PermissionCatalogEntry[] = [
},
];
// Groups registered at runtime, e.g. by plugins. Kept separate from the
// static catalog so plugin state never mutates the built-in array.
const runtimePermissionGroups = new Map<string, PermissionCatalogEntry>();
// Groups registered at runtime by plugins. Kept separate from the static
// catalog so plugin state never mutates the built-in array.
const pluginPermissionGroups = new Map<string, PermissionCatalogEntry>();
/**
* Every permission string core has ever registered, loaded from
* rbac_known_permissions at boot.
*
* This is what lets a role keep a plugin's permission while that plugin is
* disabled or uninstalled. Without it, unregistering a group made
* PUT /rbac/roles/:id reject the whole role, so an admin could not save an
* unrelated change to any role that still held one.
*/
const knownPermissions = new Set<string>();
const knownGroups = new Set<string>();
let validPermissionsCache: Set<string> | null = null;
function buildValidPermissions(): Set<string> {
return new Set<string>(
[...PERMISSION_CATALOG, ...runtimePermissionGroups.values()]
.flatMap((entry) => [...entry.permissions, `${entry.group}.*`])
.concat("*"),
);
const valid = new Set<string>(["*"]);
for (const entry of [
...PERMISSION_CATALOG,
...pluginPermissionGroups.values(),
]) {
valid.add(`${entry.group}.*`);
for (const permission of entry.permissions) valid.add(permission);
}
for (const permission of knownPermissions) valid.add(permission);
for (const group of knownGroups) valid.add(`${group}.*`);
return valid;
}
function getValidPermissions(): Set<string> {
@@ -85,27 +109,73 @@ function getValidPermissions(): Set<string> {
return validPermissionsCache;
}
export function registerPermissionGroup(entry: PermissionCatalogEntry): void {
runtimePermissionGroups.set(entry.group, entry);
/** Records permissions as known, so they stay valid without the plugin. */
export function rememberPermissions(permissions: Iterable<string>): void {
for (const permission of permissions) {
knownPermissions.add(permission);
const group = permission.split(".")[0];
if (group) knownGroups.add(group);
}
validPermissionsCache = null;
}
export function unregisterPermissionGroup(group: string): void {
runtimePermissionGroups.delete(group);
/**
* Records a group as known without naming a permission in it.
*
* A role can hold only "ai.*", with no concrete ai permission anywhere, and it
* still has to keep saving while the plugin is off.
*/
export function rememberPermissionGroup(group: string): void {
if (!group) return;
knownGroups.add(group);
validPermissionsCache = null;
}
/** Seeds the known set at boot, before any request is served. */
export function loadKnownPermissions(permissions: Iterable<string>): void {
rememberPermissions(permissions);
}
export function registerPluginPermissions(entry: PermissionCatalogEntry): void {
pluginPermissionGroups.set(entry.group, { ...entry, enabled: true });
rememberPermissions(entry.permissions);
}
/**
* Marks a plugin's group disabled without removing it.
*
* The permissions stay valid, because a role that holds one is not wrong just
* because the plugin is off. The editor greys the group instead.
*/
export function markPluginPermissionsDisabled(pluginId: string): void {
for (const [group, entry] of pluginPermissionGroups) {
if (entry.pluginId !== pluginId) continue;
pluginPermissionGroups.set(group, { ...entry, enabled: false });
}
validPermissionsCache = null;
}
// Static entries plus any groups registered at runtime.
export function getPermissionCatalog(): PermissionCatalogEntry[] {
return [...PERMISSION_CATALOG, ...runtimePermissionGroups.values()];
return [...PERMISSION_CATALOG, ...pluginPermissionGroups.values()];
}
export function isValidPermission(permission: string): boolean {
return getValidPermissions().has(permission);
}
/** Test seam. */
export function resetPermissionCatalog(): void {
pluginPermissionGroups.clear();
knownPermissions.clear();
knownGroups.clear();
validPermissionsCache = null;
}
// What the seeded system roles grant. Applied only to a role row that has no
// permissions yet, so an admin's edits to these roles survive restarts.
// Plugin permissions are not listed here; a plugin asks for its own defaults
// through contributes.permissions[].defaultRoles.
export const SYSTEM_ROLE_DEFAULTS = {
admin: {
description: "Administrator with full access",
@@ -113,12 +183,6 @@ export const SYSTEM_ROLE_DEFAULTS = {
},
user: {
description: "Regular user",
permissions: [
"hosts.*",
"snippets.*",
"automations.*",
"credentials.*",
"ai.*",
],
permissions: ["hosts.*", "snippets.*", "credentials.*"],
},
} as const;
+16
View File
@@ -268,9 +268,25 @@ export async function getHostAccess(
}
}
export interface PermissionCatalogItem {
permission: string;
/** Plugin-relative keys; resolve them with pluginKey(). */
titleKey: string;
descriptionKey: string;
}
export interface PermissionCatalogEntry {
group: string;
permissions: string[];
/** Set for a group a plugin contributed. */
pluginId?: string;
/** Core groups carry an i18n key, plugin groups their display name. */
labelKey?: string;
label?: string;
icon?: string;
/** False once the owning plugin is disabled or gone. */
enabled?: boolean;
items?: PermissionCatalogItem[];
}
export async function getPermissionsCatalog(): Promise<{
+13
View File
@@ -0,0 +1,13 @@
/**
* Where a plugin's i18n keys resolve from.
*
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
* it never has to know where the strings live. Today they live in core
* en.json under `plugins.<pluginId>.`, because the frontend does not load
* plugin locale bundles yet. When it does, that subtree moves into
* plugins/<id>/locales/en.json as the plugin's own namespace and only this
* function changes; no manifest is touched.
*/
export function pluginKey(pluginId: string, key: string): string {
return `plugins.${pluginId}.${key}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { icons, type LucideProps } from "lucide-react";
import { Puzzle } from "lucide-react";
/**
* A manifest names its icon as a Lucide component name ("Sparkles").
*
* An unknown or missing name falls back to a generic plugin glyph rather than
* rendering nothing, so a plugin shipping a typo still gets a row an admin can
* read.
*/
export function PluginIcon({
name,
...props
}: { name?: string } & LucideProps) {
const Icon = (name && icons[name as keyof typeof icons]) || Puzzle;
return <Icon {...props} />;
}
+64 -1
View File
@@ -3953,7 +3953,14 @@
"saved": "Role permissions saved",
"saveError": "Failed to save role permissions",
"save": "Save",
"saving": "Saving..."
"saving": "Saving...",
"pluginDisabled": "Plugin disabled",
"groups": {
"hosts": "Hosts",
"snippets": "Snippets",
"credentials": "Credentials",
"admin": "Administration"
}
},
"aiGloballyEnabled": "AI assistant",
"aiGloballyEnabledDesc": "Let users turn on the AI assistant. While this is off, the assistant is hidden and blocked for everyone.",
@@ -5199,6 +5206,62 @@
"title": "Add its own screens",
"consequence": "Tabs, panels and settings you can hide later."
}
},
"ai": {
"permissions": {
"use": {
"title": "Use the assistant",
"description": "Open the assistant and ask it things."
},
"manage_providers": {
"title": "Manage providers",
"description": "Add, edit and remove the AI providers and their API keys."
},
"apply_proposals": {
"title": "Apply proposals",
"description": "Let the assistant carry out the changes it suggests."
},
"services.use": {
"title": "Use AI from other plugins",
"description": "Other plugins may call the assistant on your behalf."
},
"secrets.share": {
"title": "Share the API key",
"description": "Other plugins may use the stored AI API key without seeing it."
}
}
},
"automations": {
"permissions": {
"view": {
"title": "View automations",
"description": "See the automations and their history."
},
"create": {
"title": "Create automations",
"description": "Add new automations."
},
"edit": {
"title": "Edit automations",
"description": "Change existing automations."
},
"delete": {
"title": "Delete automations",
"description": "Remove automations."
},
"run": {
"title": "Run automations",
"description": "Trigger an automation by hand."
}
}
},
"tailscale": {
"permissions": {
"devices.view": {
"title": "View tailnet devices",
"description": "See the devices on the connected tailnet."
}
}
}
}
}
+1
View File
@@ -2252,6 +2252,7 @@ export type {
SharePermissionLevel,
ShareTarget,
PermissionCatalogEntry,
PermissionCatalogItem,
} from "@/api/rbac-api";
// ============================================================================
+39 -2
View File
@@ -9,6 +9,8 @@ import {
updateRole,
} from "@/main-axios";
import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
@@ -671,8 +673,23 @@ export function AdminRolesSection({
{catalog.map((entry) => {
const wildcard = `${entry.group}.*`;
const wildcardOn = editingPermissions.has(wildcard);
// A plugin group with enabled === false is still granted,
// it just cannot be reached right now.
const offline = entry.enabled === false;
const titleFor = (permission: string) => {
const item = entry.items?.find(
(candidate) => candidate.permission === permission,
);
if (!item) return null;
return entry.pluginId
? t(pluginKey(entry.pluginId, item.titleKey))
: t(item.titleKey);
};
return (
<div key={entry.group} className="flex flex-col gap-1">
<div
key={entry.group}
className={`flex flex-col gap-1 ${offline ? "opacity-50" : ""}`}
>
<button
onClick={() => togglePermission(wildcard)}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
@@ -684,6 +701,12 @@ export function AdminRolesSection({
<Check className="size-2 text-background" />
)}
</span>
{entry.pluginId && (
<PluginIcon
name={entry.icon}
className="size-3 shrink-0 text-muted-foreground"
/>
)}
<span
className={
wildcardOn
@@ -691,13 +714,22 @@ export function AdminRolesSection({
: "text-muted-foreground"
}
>
{entry.group}.*
{entry.label ??
(entry.labelKey
? t(entry.labelKey)
: entry.group)}
</span>
{offline && (
<span className="px-1 border border-border text-[9px] font-normal normal-case tracking-normal text-muted-foreground">
{t("admin.rolePermissions.pluginDisabled")}
</span>
)}
</button>
<div className="flex flex-col gap-0.5 pl-4">
{entry.permissions.map((permission) => {
const checked =
wildcardOn || editingPermissions.has(permission);
const title = titleFor(permission);
return (
<button
key={permission}
@@ -712,6 +744,11 @@ export function AdminRolesSection({
<Check className="size-2 text-background" />
)}
</span>
{title && (
<span className="text-foreground">
{title}
</span>
)}
<span className="font-mono text-muted-foreground">
{permission}
</span>
@@ -0,0 +1,204 @@
/**
* The role editor's permission catalog: plugin grouping, the disabled state,
* and what actually gets saved.
*
* The behaviour that matters is that a disabled plugin's permission stays
* ticked and stays in the payload. Dropping it would silently revoke a grant
* just because the plugin happened to be off.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AdminRolesSection } from "../../sidebar/AdminManagementSections";
import type { PermissionCatalogEntry, Role } from "../../main-axios";
const api = vi.hoisted(() => ({
getPermissionsCatalog: vi.fn(),
updateRole: vi.fn(
async (_roleId: number, _update: { permissions?: string[] }) => ({}),
),
deleteRole: vi.fn(async () => ({})),
getRoleMembers: vi.fn(async () => ({ members: [] as unknown[] })),
getSessions: vi.fn(async () => ({ sessions: [] as unknown[] })),
revokeSession: vi.fn(async () => ({})),
}));
vi.mock("@/main-axios", () => api);
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const CATALOG: PermissionCatalogEntry[] = [
{
group: "hosts",
labelKey: "admin.rolePermissions.groups.hosts",
permissions: ["hosts.view", "hosts.create"],
},
{
group: "ai",
pluginId: "ai",
label: "AI Assistant",
icon: "Sparkles",
enabled: false,
permissions: ["ai.use"],
items: [
{
permission: "ai.use",
titleKey: "permissions.use.title",
descriptionKey: "permissions.use.description",
},
],
},
{
group: "automations",
pluginId: "automations",
label: "Automations",
icon: "Workflow",
enabled: true,
permissions: ["automations.run"],
items: [
{
permission: "automations.run",
titleKey: "permissions.run.title",
descriptionKey: "permissions.run.description",
},
],
},
];
function makeRole(permissions: string[]): Role {
return {
id: 7,
name: "operators",
displayName: "Operators",
description: null,
isSystem: false,
permissions,
createdAt: "",
updatedAt: "",
};
}
function renderSection(role: Role) {
return render(
<AdminRolesSection
open
onToggle={() => {}}
roles={[role]}
setRoles={() => {}}
showCreateRole={false}
setShowCreateRole={() => {}}
newRoleName=""
setNewRoleName={() => {}}
newRoleDisplayName=""
setNewRoleDisplayName={() => {}}
newRoleDescription=""
setNewRoleDescription={() => {}}
handleCreateRole={() => {}}
createRoleLoading={false}
/>,
);
}
async function openEditor(role: Role) {
const user = userEvent.setup();
renderSection(role);
await user.click(
screen.getByRole("button", { name: /admin.rolePermissions.editAction/i }),
);
await waitFor(() => expect(api.getPermissionsCatalog).toHaveBeenCalled());
return user;
}
beforeEach(() => {
vi.clearAllMocks();
api.getPermissionsCatalog.mockResolvedValue({ catalog: CATALOG });
});
describe("role permission groups", () => {
it("labels a core group by its i18n key, not the raw wildcard", async () => {
await openEditor(makeRole([]));
expect(screen.getByText("admin.rolePermissions.groups.hosts")).toBeTruthy();
expect(screen.queryByText("hosts.*")).toBeNull();
});
it("labels a plugin group with the plugin's display name", async () => {
await openEditor(makeRole([]));
expect(screen.getByText("AI Assistant")).toBeTruthy();
expect(screen.getByText("Automations")).toBeTruthy();
});
it("shows a plugin permission's title beside its raw id", async () => {
await openEditor(makeRole([]));
expect(
screen.getByText("plugins.automations.permissions.run.title"),
).toBeTruthy();
expect(screen.getByText("automations.run")).toBeTruthy();
});
it("marks a disabled plugin's group", async () => {
await openEditor(makeRole([]));
expect(
screen.getByText("admin.rolePermissions.pluginDisabled"),
).toBeTruthy();
});
it("does not mark an enabled plugin's group", async () => {
await openEditor(makeRole([]));
expect(
screen.getAllByText("admin.rolePermissions.pluginDisabled"),
).toHaveLength(1);
});
it("lists a group once per catalog entry", async () => {
await openEditor(makeRole([]));
expect(screen.getAllByText("AI Assistant")).toHaveLength(1);
});
});
describe("saving a role", () => {
// The regression A5 fixes: the permission must survive the round trip even
// though its plugin is disabled.
it("keeps a disabled plugin's permission in the payload", async () => {
const role = makeRole(["hosts.view", "ai.use"]);
const user = await openEditor(role);
await user.click(
screen.getByRole("button", { name: /admin.rolePermissions.save/i }),
);
await waitFor(() => expect(api.updateRole).toHaveBeenCalled());
const [, payload] = api.updateRole.mock.calls[0];
expect(payload.permissions).toContain("ai.use");
expect(payload.permissions).toContain("hosts.view");
});
it("still lets a disabled plugin's permission be revoked deliberately", async () => {
const role = makeRole(["hosts.view", "ai.use"]);
const user = await openEditor(role);
await user.click(screen.getByText("ai.use"));
await user.click(
screen.getByRole("button", { name: /admin.rolePermissions.save/i }),
);
await waitFor(() => expect(api.updateRole).toHaveBeenCalled());
const [, payload] = api.updateRole.mock.calls[0];
expect(payload.permissions).not.toContain("ai.use");
expect(payload.permissions).toContain("hosts.view");
});
});