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

+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE `rbac_applied_defaults` (
`id` int AUTO_INCREMENT NOT NULL,
`role_name` varchar(255) NOT NULL,
`permission` varchar(255) NOT NULL,
`applied_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `rbac_applied_defaults_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_rbac_applied_defaults_role_permission` UNIQUE(`role_name`,`permission`)
);
--> statement-breakpoint
CREATE TABLE `rbac_known_permissions` (
`id` int AUTO_INCREMENT NOT NULL,
`permission` varchar(255) NOT NULL,
`plugin_id` varchar(255),
`first_seen_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `rbac_known_permissions_id` PRIMARY KEY(`id`),
CONSTRAINT `idx_rbac_known_permissions_permission` UNIQUE(`permission`)
);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -197,6 +197,13 @@
"when": 1790109037476,
"tag": "0027_watery_multiple_man",
"breakpoints": true
},
{
"idx": 28,
"version": "5",
"when": 1790114765594,
"tag": "0028_fair_tusk",
"breakpoints": true
}
]
}
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "rbac_applied_defaults" (
"id" serial PRIMARY KEY NOT NULL,
"role_name" varchar(255) NOT NULL,
"permission" varchar(255) NOT NULL,
"applied_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE TABLE "rbac_known_permissions" (
"id" serial PRIMARY KEY NOT NULL,
"permission" varchar(255) NOT NULL,
"plugin_id" varchar(255),
"first_seen_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "idx_rbac_applied_defaults_role_permission" ON "rbac_applied_defaults" USING btree ("role_name","permission");--> statement-breakpoint
CREATE UNIQUE INDEX "idx_rbac_known_permissions_permission" ON "rbac_known_permissions" USING btree ("permission");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -204,6 +204,13 @@
"when": 1790109036999,
"tag": "0028_whole_zemo",
"breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1790114765114,
"tag": "0029_nosy_roulette",
"breakpoints": true
}
]
}
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE `rbac_applied_defaults` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`role_name` text NOT NULL,
`permission` text NOT NULL,
`applied_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_rbac_applied_defaults_role_permission` ON `rbac_applied_defaults` (`role_name`,`permission`);--> statement-breakpoint
CREATE TABLE `rbac_known_permissions` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`permission` text NOT NULL,
`plugin_id` text,
`first_seen_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_rbac_known_permissions_permission` ON `rbac_known_permissions` (`permission`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -176,6 +176,13 @@
"when": 1790109036510,
"tag": "0024_public_hemingway",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1790114764617,
"tag": "0025_overrated_cable",
"breakpoints": true
}
]
}
+57 -20
View File
@@ -67,14 +67,44 @@ text) plus the SDK method that checks it.
These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they
can never be confused with colon-separated capabilities.
A plugin declares short names in `contributes.permissionGroup`. Core registers
them, groups them under the plugin in the role editor, and only lets a plugin
set defaults for its own namespace: a `defaultForRole` entry naming a
permission the plugin does not declare is a validation error, not a silent
escalation. Permissions stored in a role survive the plugin being disabled.
**A5 delivered this.** A plugin declares short names in
`contributes.permissions`, and core registers each as `<pluginId>.<name>`. The
plugin never writes the prefix, so it cannot claim a core group or another
plugin's namespace: `parseManifest` refuses a name starting with `hosts`,
`snippets`, `credentials`, `admin` or the plugin's own id, and the runtime
re-checks against every namespace already in the catalog, because the validator
cannot know which other plugins exist.
Role defaults are applied once and the fact is recorded, so an admin who
revokes one does not get it handed back on the next restart.
The ids are unchanged by the move. `ai` + `use` is `ai.use`, which is exactly
what the core catalog held before, so no role had to be migrated when `ai.*` and
`automations.*` left `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS`.
**A permission outlives its plugin.** Core records every id it has ever
registered in `rbac_known_permissions`, loaded at boot before any request is
served and backfilled from what roles already hold. Disabling a plugin now only
marks its group `enabled: false`: the group stays in the catalog so the role
editor can grey it, and `PUT /rbac/roles/:id` keeps accepting it. Previously the
group was unregistered outright, so an admin could not save _any_ change to a
role that still held one of these while the plugin was off. A permission nobody
has ever registered is still rejected.
**Defaults apply once.** `defaultRoles` names the seeded system roles (`admin`,
`user`) and nothing else. Each application is recorded in
`rbac_applied_defaults`, a core table with no foreign key to `plugins`, so an
admin who revokes a default does not get it handed back on the next boot or by
reinstalling the plugin. That ledger used to live in `plugin_storage`, which
cascades with the plugin row.
Both tables are deliberately unlinked from `plugins`: the whole point is
surviving the plugin being gone.
`ctx.rbac` is the plugin-side view: `has(permission)` for the current actor,
`hasFor(userId, permission)`, and `require(permission)` as route middleware. All
three resolve a bare short name against this plugin's prefix and take a full id
belonging to a core group or another plugin as given, which is what makes a
cross-plugin check expressible. `require` additionally refuses anything the
plugin does not declare itself, at registration time, and denies with the same
`401`/`403` bodies `requirePermission()` sends.
### 5. Data
@@ -332,11 +362,14 @@ not do.
},
],
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
"permissionGroup": {
"group": "example",
"permissions": ["example.use"],
"defaultForRole": { "admin": ["example.use"] }, // own namespace only
},
"permissions": [
{
"name": "use", // registered as "example.use"
"titleKey": "permissions.use.title", // plugin-relative
"descriptionKey": "permissions.use.description",
"defaultRoles": ["user"], // system roles only, applied once
},
],
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
@@ -361,17 +394,22 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- `engine.api` must match the SDK major version this build implements.
- Every `provides[].permission`, `providesSecret[].permission` and
`contributes.actions[].permission` must appear in the plugin's own
`contributes.permissionGroup.permissions`. A permission the catalog never
sees is one no admin can grant, so the surface would be invisible rather
than denied.
`contributes.permissions`, compared against the qualified `<pluginId>.<name>`
form. A permission the catalog never sees is one no admin can grant, so the
surface would be invisible rather than denied.
- `requiresSecret[].plugin` and both dependency maps must not name the plugin
itself, and a plugin cannot be in both `dependencies` and
`optionalDependencies`.
- Every `defaultForRole` entry must be a permission the plugin declares.
- Every `contributes.permissions[].name` must be unique, must not start with a
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
own id, and every `defaultRoles` entry must be `admin` or `user`.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. A5, A6 and A7 reshape them.
secret registries read them at activation. A6 and A7 reshape them.
Removed in A5: `contributes.permissionGroup` and its `defaultForRole`, replaced
by `contributes.permissions`. A plugin no longer picks its own group name.
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
@@ -446,7 +484,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.require` | own permissions only | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
| `ctx.settings.*` | `settings:read-core` | A6 |
@@ -650,8 +688,7 @@ Known specifics:
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage
30012. Each keeps its nginx block until its own Phase B step. No plugin owns
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns
a port any more (A4).
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
+17 -18
View File
@@ -244,26 +244,25 @@
}
}
},
"permissionGroup": {
"type": "object",
"additionalProperties": false,
"required": ["group", "permissions"],
"properties": {
"group": { "type": "string", "minLength": 1 },
"permissions": {
"type": "array",
"minItems": 1,
"items": {
"permissions": {
"type": "array",
"description": "Role permissions this plugin contributes. Core registers each as <pluginId>.<name>.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "titleKey", "descriptionKey"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9_-]+)+$"
}
},
"defaultForRole": {
"type": "object",
"description": "Role name -> permissions. Only permissions this plugin declares above are allowed.",
"additionalProperties": {
"description": "Plugin-relative name. May contain dots. Must not start with a core group or this plugin's id.",
"pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$"
},
"titleKey": { "type": "string", "minLength": 1 },
"descriptionKey": { "type": "string", "minLength": 1 },
"defaultRoles": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
"description": "System roles that hold this the first time it is seen.",
"items": { "type": "string", "enum": ["admin", "user"] }
}
}
}
+17 -3
View File
@@ -213,14 +213,28 @@ export interface PluginHttp {
router: <T = unknown>(options?: PluginRouterOptions) => T;
}
/** RBAC middleware for a plugin's own routes. */
/**
* Core RBAC, as a plugin sees it.
*
* Every method takes either a short name this plugin declares
* ("services.use"), which is prefixed with the plugin id, or a full id
* belonging to another plugin or a core group, which is used as given. That is
* what makes a cross-plugin check expressible without letting a plugin gate its
* own routes on someone else's authority.
*/
export interface PluginRbac {
/** Whether the user this call is running as holds `permission`. */
has: (permission: string) => Promise<boolean>;
/** Whether `userId` holds `permission`. */
hasFor: (userId: string, permission: string) => Promise<boolean>;
/**
* Middleware that rejects a request whose user lacks `permission`.
*
* A plugin may only require a permission it declares in
* contributes.permissionGroup, so a route cannot gate on admin.users.manage
* and borrow someone else's authority.
* contributes.permissions, so a route cannot gate on admin.users.manage and
* borrow someone else's authority.
*/
require: (permission: string) => PluginMiddleware;
}
+121 -72
View File
@@ -46,6 +46,31 @@ const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
/**
* A plugin-relative permission name. Unlike a full id one segment is fine,
* and underscores are allowed in the first segment too ("manage_providers").
*/
const PERMISSION_NAME_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/;
/** The roles core seeds. A plugin may only set defaults for these. */
export const SYSTEM_ROLE_NAMES = ["admin", "user"] as const;
export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
/**
* Core permission groups. A plugin permission may not start with one of these,
* or a manifest could declare admin.users.manage and gate a route on it.
*/
export const RESERVED_PERMISSION_PREFIXES = [
"hosts",
"snippets",
"credentials",
"admin",
] as const;
/** The id a plugin-relative permission name is registered under. */
export function qualifyPermission(pluginId: string, name: string): string {
return `${pluginId}.${name}`;
}
export interface PluginAuthor {
name: string;
@@ -72,11 +97,19 @@ export interface PluginTabContribution {
openFrom: string[];
}
export interface PluginPermissionGroup {
group: string;
permissions: string[];
/** Role name -> permissions. Only this plugin's own permissions are allowed. */
defaultForRole?: Record<string, string[]>;
/**
* One role permission a plugin contributes.
*
* `name` is short and plugin-relative; core registers it as
* `<pluginId>.<name>`, so a plugin can never name a core group or another
* plugin's namespace. The i18n keys are plugin-relative too.
*/
export interface PluginPermissionContribution {
name: string;
titleKey: string;
descriptionKey: string;
/** System roles that should hold this the first time it is seen. */
defaultRoles?: SystemRoleName[];
}
export interface PluginActionContribution {
@@ -125,7 +158,7 @@ export interface PluginContributions {
tabs?: PluginTabContribution[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissionGroup?: PluginPermissionGroup;
permissions?: PluginPermissionContribution[];
/**
* A host-editor checkbox backed by a boolean column on the host record.
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
@@ -202,7 +235,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"actions",
"actionSlots",
"permissionGroup",
"permissions",
"hostCapability",
]);
@@ -288,7 +321,11 @@ export function validateManifest(manifest: unknown): string[] {
validateRequires(m.requires, errors);
validateProvidesSecret(m.providesSecret, errors);
validateRequiresSecret(m.requiresSecret, errors);
validateContributes(m.contributes, errors);
validateContributes(
m.contributes,
typeof m.id === "string" ? m.id : undefined,
errors,
);
return errors;
}
@@ -511,7 +548,11 @@ function validateRequiresSecret(value: unknown, errors: string[]): void {
});
}
function validateContributes(contributes: unknown, errors: string[]): void {
function validateContributes(
contributes: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (contributes === undefined) return;
if (!isPlainObject(contributes)) {
errors.push('Field "contributes" must be an object');
@@ -520,7 +561,7 @@ function validateContributes(contributes: unknown, errors: string[]): void {
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
validateTabs(contributes.tabs, errors);
validatePermissionGroup(contributes.permissionGroup, errors);
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
validateHostCapability(contributes.hostCapability, errors);
@@ -562,74 +603,78 @@ function validateTabs(tabs: unknown, errors: string[]): void {
});
}
function validatePermissionGroup(group: unknown, errors: string[]): void {
if (group === undefined) return;
const where = "contributes.permissionGroup";
if (!isPlainObject(group)) {
errors.push(`${where} must be an object`);
function validatePermissions(
permissions: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (permissions === undefined) return;
const where = "contributes.permissions";
if (!Array.isArray(permissions)) {
errors.push(`${where} must be an array`);
return;
}
rejectUnknown(
group,
["group", "permissions", "defaultForRole"],
where,
errors,
);
requireString(group.group, `${where}.group`, errors);
const seen = new Set<string>();
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(`${where}.permissions must be a non-empty array`);
} else {
group.permissions.forEach((permission: unknown, index: number) => {
permissions.forEach((entry: unknown, index: number) => {
const at = `${where}[${index}]`;
if (!isPlainObject(entry)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(
entry,
["name", "titleKey", "descriptionKey", "defaultRoles"],
at,
errors,
);
const name = entry.name;
if (typeof name !== "string" || !PERMISSION_NAME_PATTERN.test(name)) {
errors.push(`${at}.name must be a lowercase, dotted permission name`);
} else {
if (seen.has(name)) {
errors.push(`${at}.name "${name}" is declared more than once`);
}
seen.add(name);
// Core groups first: a plugin naming one could gate a route on core
// authority it was never given.
const head = name.split(".")[0];
if ((RESERVED_PERMISSION_PREFIXES as readonly string[]).includes(head)) {
errors.push(
`${at}.name "${name}" starts with the reserved core group "${head}". Permissions are registered as <pluginId>.<name>, so drop the prefix.`,
);
}
// "ai" declaring "ai.use" would register as ai.ai.use, which is always
// a mistake rather than an intent.
if (pluginId && (name === pluginId || head === pluginId)) {
errors.push(
`${at}.name "${name}" already starts with this plugin's id. Permissions are registered as <pluginId>.<name>, so drop the prefix.`,
);
}
}
requireString(entry.titleKey, `${at}.titleKey`, errors);
requireString(entry.descriptionKey, `${at}.descriptionKey`, errors);
if (entry.defaultRoles === undefined) return;
if (!Array.isArray(entry.defaultRoles)) {
errors.push(`${at}.defaultRoles must be an array`);
return;
}
entry.defaultRoles.forEach((role: unknown, roleIndex: number) => {
if (
typeof permission !== "string" ||
!PERMISSION_PATTERN.test(permission)
typeof role !== "string" ||
!(SYSTEM_ROLE_NAMES as readonly string[]).includes(role)
) {
errors.push(
`${where}.permissions[${index}] must be a dotted permission name`,
`${at}.defaultRoles[${roleIndex}] must be one of: ${SYSTEM_ROLE_NAMES.join(", ")}`,
);
}
});
}
if (group.defaultForRole === undefined) return;
if (!isPlainObject(group.defaultForRole)) {
errors.push(
`${where}.defaultForRole must be an object of role -> permissions`,
);
return;
}
const declared = new Set(
Array.isArray(group.permissions)
? (group.permissions as unknown[]).filter(
(value): value is string => typeof value === "string",
)
: [],
);
for (const [role, wanted] of Object.entries(group.defaultForRole)) {
if (!Array.isArray(wanted)) {
errors.push(`${where}.defaultForRole["${role}"] must be an array`);
continue;
}
wanted.forEach((permission: unknown, index: number) => {
if (typeof permission !== "string") {
errors.push(
`${where}.defaultForRole["${role}"][${index}] must be a string`,
);
return;
}
// A plugin may only suggest defaults for permissions it declares itself.
// Without this a manifest could add admin.* to any role at boot.
if (!declared.has(permission)) {
errors.push(
`${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`,
);
}
});
}
});
}
function validateActions(actions: unknown, errors: string[]): void {
@@ -774,8 +819,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
};
}
// Full ids: provides, providesSecret and actions all name permissions the
// way an admin sees them, so they are compared against the qualified form.
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
(manifest.contributes?.permissions ?? []).map((permission) =>
qualifyPermission(manifest.id, permission.name),
),
);
// A permission the catalog never sees is one no admin can grant, so the
@@ -783,21 +832,21 @@ export function parseManifest(raw: unknown): ParsedManifest {
for (const provide of manifest.provides ?? []) {
if (!declared.has(provide.permission)) {
errors.push(
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissionGroup.permissions`,
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissions`,
);
}
}
for (const secret of manifest.providesSecret ?? []) {
if (!declared.has(secret.permission)) {
errors.push(
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissionGroup.permissions`,
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissions`,
);
}
}
for (const action of manifest.contributes?.actions ?? []) {
if (action.permission && !declared.has(action.permission)) {
errors.push(
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissionGroup.permissions`,
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissions`,
);
}
}
+2
View File
@@ -181,6 +181,8 @@ export function createFakeContext(
rbac: {
// Passes everything through: a test asserting a permission gate should
// drive PermissionManager, not this double.
has: async () => true,
hasFor: async () => true,
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
},
+31 -12
View File
@@ -56,19 +56,38 @@
"kind": "button"
}
],
"permissionGroup": {
"group": "ai",
"permissions": [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
"ai.services.use",
"ai.secrets.share"
],
"defaultForRole": {
"admin": ["ai.services.use"]
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description",
"defaultRoles": ["user"]
},
{
"name": "manage_providers",
"titleKey": "permissions.manage_providers.title",
"descriptionKey": "permissions.manage_providers.description",
"defaultRoles": ["user"]
},
{
"name": "apply_proposals",
"titleKey": "permissions.apply_proposals.title",
"descriptionKey": "permissions.apply_proposals.description",
"defaultRoles": ["user"]
},
{
"name": "services.use",
"titleKey": "permissions.services.use.title",
"descriptionKey": "permissions.services.use.description",
"defaultRoles": ["user"]
},
{
"name": "secrets.share",
"titleKey": "permissions.secrets.share.title",
"descriptionKey": "permissions.secrets.share.description",
"defaultRoles": ["user"]
}
}
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest, qualifyPermission } from "@termix/plugin-sdk/manifest";
/**
* The permission ids an admin grants.
*
* Core registers each declared name as `<pluginId>.<name>`, so these strings
* are what end up stored in a role. They have to keep matching what ai
* shipped before the permissions moved out of the core catalog, or every role
* holding one would silently stop working.
*/
const EXPECTED = [
"ai.use",
"ai.manage_providers",
"ai.apply_proposals",
"ai.services.use",
"ai.secrets.share",
];
describe("ai permissions", () => {
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("registers exactly the ids roles already hold", () => {
const ids = (manifest.manifest?.contributes?.permissions ?? []).map(
(permission) => qualifyPermission("ai", permission.name),
);
expect(ids).toEqual(EXPECTED);
});
it("declares a title and description for every permission", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.titleKey).toBeTruthy();
expect(permission.descriptionKey).toBeTruthy();
}
});
// Core used to grant these through SYSTEM_ROLE_DEFAULTS, so a fresh install
// has to keep giving them to the user role.
it("keeps the fresh-install defaults core used to seed", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.defaultRoles).toEqual(["user"]);
}
});
});
+34
View File
@@ -26,6 +26,40 @@
"network:serve",
"ui:surface"
],
"contributes": {
"permissions": [
{
"name": "view",
"titleKey": "permissions.view.title",
"descriptionKey": "permissions.view.description",
"defaultRoles": ["user"]
},
{
"name": "create",
"titleKey": "permissions.create.title",
"descriptionKey": "permissions.create.description",
"defaultRoles": ["user"]
},
{
"name": "edit",
"titleKey": "permissions.edit.title",
"descriptionKey": "permissions.edit.description",
"defaultRoles": ["user"]
},
{
"name": "delete",
"titleKey": "permissions.delete.title",
"descriptionKey": "permissions.delete.description",
"defaultRoles": ["user"]
},
{
"name": "run",
"titleKey": "permissions.run.title",
"descriptionKey": "permissions.run.description",
"defaultRoles": ["user"]
}
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest, qualifyPermission } from "@termix/plugin-sdk/manifest";
/**
* The permission ids an admin grants.
*
* Core registers each declared name as `<pluginId>.<name>`, so these strings
* are what end up stored in a role. They have to keep matching what automations
* shipped before the permissions moved out of the core catalog, or every role
* holding one would silently stop working.
*/
const EXPECTED = [
"automations.view",
"automations.create",
"automations.edit",
"automations.delete",
"automations.run",
];
describe("automations permissions", () => {
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("registers exactly the ids roles already hold", () => {
const ids = (manifest.manifest?.contributes?.permissions ?? []).map(
(permission) => qualifyPermission("automations", permission.name),
);
expect(ids).toEqual(EXPECTED);
});
it("declares a title and description for every permission", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.titleKey).toBeTruthy();
expect(permission.descriptionKey).toBeTruthy();
}
});
// Core used to grant these through SYSTEM_ROLE_DEFAULTS, so a fresh install
// has to keep giving them to the user role.
it("keeps the fresh-install defaults core used to seed", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.defaultRoles).toEqual(["user"]);
}
});
});
@@ -16,7 +16,11 @@
export const id = "network-topology";
export const tabId = "network_graph";
export async function register({ registerRailItem, registerTabComponent, icons }) {
export async function register({
registerRailItem,
registerTabComponent,
icons,
}) {
registerTabComponent?.(tabId, () =>
import("./NetworkGraphCard.tsx").then((m) => ({
default: m.NetworkGraphCard,
@@ -31,7 +35,10 @@ export async function register({ registerRailItem, registerTabComponent, icons }
});
}
export async function unregister({ unregisterRailItem, unregisterTabComponent }) {
export async function unregister({
unregisterRailItem,
unregisterTabComponent,
}) {
unregisterRailItem?.(tabId);
unregisterTabComponent?.(tabId);
}
+7 -4
View File
@@ -21,10 +21,13 @@
"ui:surface"
],
"contributes": {
"permissionGroup": {
"group": "tailscale",
"permissions": ["tailscale.devices.view"]
}
"permissions": [
{
"name": "devices.view",
"titleKey": "permissions.devices.view.title",
"descriptionKey": "permissions.devices.view.description"
}
]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { parseManifest, qualifyPermission } from "@termix/plugin-sdk/manifest";
/**
* The permission ids an admin grants.
*
* Core registers each declared name as `<pluginId>.<name>`, so these strings
* are what end up stored in a role. They have to keep matching what tailscale
* shipped before the permissions moved out of the core catalog, or every role
* holding one would silently stop working.
*/
const EXPECTED = ["tailscale.devices.view"];
describe("tailscale permissions", () => {
const manifest = parseManifest(
JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname, "../../manifest.json"),
"utf8",
),
),
);
it("parses cleanly", () => {
expect(manifest.errors).toEqual([]);
});
it("registers exactly the ids roles already hold", () => {
const ids = (manifest.manifest?.contributes?.permissions ?? []).map(
(permission) => qualifyPermission("tailscale", permission.name),
);
expect(ids).toEqual(EXPECTED);
});
it("declares a title and description for every permission", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.titleKey).toBeTruthy();
expect(permission.descriptionKey).toBeTruthy();
}
});
// No wildcard covered tailscale in SYSTEM_ROLE_DEFAULTS, so nothing is
// granted by default and an admin has to opt in.
it("grants nothing by default", () => {
for (const permission of manifest.manifest?.contributes?.permissions ??
[]) {
expect(permission.defaultRoles).toBeUndefined();
}
});
});
@@ -14,12 +14,12 @@
},
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"],
"defaultForRole": {
"user": ["admin.users.manage"]
"permissions": [
{
"name": "admin.users.manage",
"titleKey": "permissions.admin.users.manage.title",
"descriptionKey": "permissions.admin.users.manage.description"
}
}
]
}
}
@@ -14,10 +14,13 @@
},
"capabilities": ["kv:own", "ui:surface"],
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
},
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description"
}
],
"actions": [
{
"id": "sample.open",
@@ -21,9 +21,12 @@
}
],
"contributes": {
"permissionGroup": {
"group": "sample-plugin",
"permissions": ["sample-plugin.use"]
}
"permissions": [
{
"name": "use",
"titleKey": "permissions.use.title",
"descriptionKey": "permissions.use.description"
}
]
}
}
+3 -3
View File
@@ -79,9 +79,9 @@ describe("validate-plugin-manifest.cjs", () => {
expect(output).toContain("extra");
});
// A manifest must not be able to hand a role a permission the plugin does
// not own, which is how a plugin could otherwise grant itself admin rights.
it("rejects a role default outside the plugin's own namespace", () => {
// A manifest must not be able to name a core namespace, which is how a
// plugin could otherwise gate a route on admin authority it never had.
it("rejects a permission name outside the plugin's own namespace", () => {
const { status, output } = runValidator(
"invalid-role-default-escalation.json",
);
+4 -6
View File
@@ -135,12 +135,10 @@ check("host really deleted", await hosts.findById(host.id), null);
// A plugin's own tables. The DDL is emitted from a table definition rather
// than written by hand, so this is what proves the emitter's output is valid
// on a real engine - the unit tests can only snapshot the string.
const { defineTable, id, varchar, text, refUser, timestamp } = await import(
"../packages/plugin-sdk/dist/db.js"
);
const { createTableSql, dropTableSql } = await import(
"../packages/plugin-sdk/dist/ddl.js"
);
const { defineTable, id, varchar, text, refUser, timestamp } =
await import("../packages/plugin-sdk/dist/db.js");
const { createTableSql, dropTableSql } =
await import("../packages/plugin-sdk/dist/ddl.js");
const { sql: raw } = await import("drizzle-orm");
const pluginTable = defineTable(
+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");
});
});