mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
@@ -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 ---
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<{
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2252,6 +2252,7 @@ export type {
|
||||
SharePermissionLevel,
|
||||
ShareTarget,
|
||||
PermissionCatalogEntry,
|
||||
PermissionCatalogItem,
|
||||
} from "@/api/rbac-api";
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user