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