feat: namespace plugin permissions and keep them valid when a plugin is off

This commit is contained in:
LukeGus committed 2026-09-22 17:30:00 -05:00
1 parent 8431570ff7
commit cc15559ea1
53 files changed
+31442 -351

No files matched your search

+57 -20
View File
@@ -67,14 +67,44 @@ text) plus the SDK method that checks it.
These stay in core RBAC and keep their dotted spelling (`hosts.view`), so they
can never be confused with colon-separated capabilities.
A plugin declares short names in `contributes.permissionGroup`. Core registers
them, groups them under the plugin in the role editor, and only lets a plugin
set defaults for its own namespace: a `defaultForRole` entry naming a
permission the plugin does not declare is a validation error, not a silent
escalation. Permissions stored in a role survive the plugin being disabled.
**A5 delivered this.** A plugin declares short names in
`contributes.permissions`, and core registers each as `<pluginId>.<name>`. The
plugin never writes the prefix, so it cannot claim a core group or another
plugin's namespace: `parseManifest` refuses a name starting with `hosts`,
`snippets`, `credentials`, `admin` or the plugin's own id, and the runtime
re-checks against every namespace already in the catalog, because the validator
cannot know which other plugins exist.
Role defaults are applied once and the fact is recorded, so an admin who
revokes one does not get it handed back on the next restart.
The ids are unchanged by the move. `ai` + `use` is `ai.use`, which is exactly
what the core catalog held before, so no role had to be migrated when `ai.*` and
`automations.*` left `PERMISSION_CATALOG` and `SYSTEM_ROLE_DEFAULTS`.
**A permission outlives its plugin.** Core records every id it has ever
registered in `rbac_known_permissions`, loaded at boot before any request is
served and backfilled from what roles already hold. Disabling a plugin now only
marks its group `enabled: false`: the group stays in the catalog so the role
editor can grey it, and `PUT /rbac/roles/:id` keeps accepting it. Previously the
group was unregistered outright, so an admin could not save _any_ change to a
role that still held one of these while the plugin was off. A permission nobody
has ever registered is still rejected.
**Defaults apply once.** `defaultRoles` names the seeded system roles (`admin`,
`user`) and nothing else. Each application is recorded in
`rbac_applied_defaults`, a core table with no foreign key to `plugins`, so an
admin who revokes a default does not get it handed back on the next boot or by
reinstalling the plugin. That ledger used to live in `plugin_storage`, which
cascades with the plugin row.
Both tables are deliberately unlinked from `plugins`: the whole point is
surviving the plugin being gone.
`ctx.rbac` is the plugin-side view: `has(permission)` for the current actor,
`hasFor(userId, permission)`, and `require(permission)` as route middleware. All
three resolve a bare short name against this plugin's prefix and take a full id
belonging to a core group or another plugin as given, which is what makes a
cross-plugin check expressible. `require` additionally refuses anything the
plugin does not declare itself, at registration time, and denies with the same
`401`/`403` bodies `requirePermission()` sends.
### 5. Data
@@ -332,11 +362,14 @@ not do.
},
],
"actionSlots": [{ "id": "example.toolbar", "accepts": ["button"] }],
"permissionGroup": {
"group": "example",
"permissions": ["example.use"],
"defaultForRole": { "admin": ["example.use"] }, // own namespace only
},
"permissions": [
{
"name": "use", // registered as "example.use"
"titleKey": "permissions.use.title", // plugin-relative
"descriptionKey": "permissions.use.description",
"defaultRoles": ["user"], // system roles only, applied once
},
],
"hostCapability": {
"key": "enableExample",
"labelKey": "k",
@@ -361,17 +394,22 @@ Cross-field rules the JSON schema cannot express, checked by `parseManifest`:
- `engine.api` must match the SDK major version this build implements.
- Every `provides[].permission`, `providesSecret[].permission` and
`contributes.actions[].permission` must appear in the plugin's own
`contributes.permissionGroup.permissions`. A permission the catalog never
sees is one no admin can grant, so the surface would be invisible rather
than denied.
`contributes.permissions`, compared against the qualified `<pluginId>.<name>`
form. A permission the catalog never sees is one no admin can grant, so the
surface would be invisible rather than denied.
- `requiresSecret[].plugin` and both dependency maps must not name the plugin
itself, and a plugin cannot be in both `dependencies` and
`optionalDependencies`.
- Every `defaultForRole` entry must be a permission the plugin declares.
- Every `contributes.permissions[].name` must be unique, must not start with a
core group (`hosts`, `snippets`, `credentials`, `admin`) or with the plugin's
own id, and every `defaultRoles` entry must be `admin` or `user`.
`contributes.hostCapability`, `provides`, `requires`, `providesSecret` and
`requiresSecret` are carried forward from v1 unchanged because the service and
secret registries read them at activation. A5, A6 and A7 reshape them.
secret registries read them at activation. A6 and A7 reshape them.
Removed in A5: `contributes.permissionGroup` and its `defaultForRole`, replaced
by `contributes.permissions`. A plugin no longer picks its own group name.
Removed in v2: `permissions` (replaced by `capabilities`), `sidecars`, the
`capabilities.{backend,frontend,electron,platforms}` object (replaced by the
@@ -446,7 +484,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.require` | own permissions only | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | B |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | B |
| `ctx.settings.*` | `settings:read-core` | A6 |
@@ -650,8 +688,7 @@ Known specifics:
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- Core feature servers that are not plugins yet still own ports: tunnel 30003,
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage
30012. Each keeps its nginx block until its own Phase B step. No plugin owns
file-manager 30004, dashboard 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx block until its own Phase B step. No plugin owns
a port any more (A4).
- `src/ui/shell/pluginLoader.ts` still hardcodes plugin ids in
`BUILT_IN_TABS_BY_PLUGIN` and `applyFirstPartyActions`. A7 removes both.
+17 -18
View File
@@ -244,26 +244,25 @@
}
}
},
"permissionGroup": {
"type": "object",
"additionalProperties": false,
"required": ["group", "permissions"],
"properties": {
"group": { "type": "string", "minLength": 1 },
"permissions": {
"type": "array",
"minItems": 1,
"items": {
"permissions": {
"type": "array",
"description": "Role permissions this plugin contributes. Core registers each as <pluginId>.<name>.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "titleKey", "descriptionKey"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9-]+(\\.[a-z0-9_-]+)+$"
}
},
"defaultForRole": {
"type": "object",
"description": "Role name -> permissions. Only permissions this plugin declares above are allowed.",
"additionalProperties": {
"description": "Plugin-relative name. May contain dots. Must not start with a core group or this plugin's id.",
"pattern": "^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$"
},
"titleKey": { "type": "string", "minLength": 1 },
"descriptionKey": { "type": "string", "minLength": 1 },
"defaultRoles": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
"description": "System roles that hold this the first time it is seen.",
"items": { "type": "string", "enum": ["admin", "user"] }
}
}
}
+17 -3
View File
@@ -213,14 +213,28 @@ export interface PluginHttp {
router: <T = unknown>(options?: PluginRouterOptions) => T;
}
/** RBAC middleware for a plugin's own routes. */
/**
* Core RBAC, as a plugin sees it.
*
* Every method takes either a short name this plugin declares
* ("services.use"), which is prefixed with the plugin id, or a full id
* belonging to another plugin or a core group, which is used as given. That is
* what makes a cross-plugin check expressible without letting a plugin gate its
* own routes on someone else's authority.
*/
export interface PluginRbac {
/** Whether the user this call is running as holds `permission`. */
has: (permission: string) => Promise<boolean>;
/** Whether `userId` holds `permission`. */
hasFor: (userId: string, permission: string) => Promise<boolean>;
/**
* Middleware that rejects a request whose user lacks `permission`.
*
* A plugin may only require a permission it declares in
* contributes.permissionGroup, so a route cannot gate on admin.users.manage
* and borrow someone else's authority.
* contributes.permissions, so a route cannot gate on admin.users.manage and
* borrow someone else's authority.
*/
require: (permission: string) => PluginMiddleware;
}
+121 -72
View File
@@ -46,6 +46,31 @@ const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
/**
* A plugin-relative permission name. Unlike a full id one segment is fine,
* and underscores are allowed in the first segment too ("manage_providers").
*/
const PERMISSION_NAME_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/;
/** The roles core seeds. A plugin may only set defaults for these. */
export const SYSTEM_ROLE_NAMES = ["admin", "user"] as const;
export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number];
/**
* Core permission groups. A plugin permission may not start with one of these,
* or a manifest could declare admin.users.manage and gate a route on it.
*/
export const RESERVED_PERMISSION_PREFIXES = [
"hosts",
"snippets",
"credentials",
"admin",
] as const;
/** The id a plugin-relative permission name is registered under. */
export function qualifyPermission(pluginId: string, name: string): string {
return `${pluginId}.${name}`;
}
export interface PluginAuthor {
name: string;
@@ -72,11 +97,19 @@ export interface PluginTabContribution {
openFrom: string[];
}
export interface PluginPermissionGroup {
group: string;
permissions: string[];
/** Role name -> permissions. Only this plugin's own permissions are allowed. */
defaultForRole?: Record<string, string[]>;
/**
* One role permission a plugin contributes.
*
* `name` is short and plugin-relative; core registers it as
* `<pluginId>.<name>`, so a plugin can never name a core group or another
* plugin's namespace. The i18n keys are plugin-relative too.
*/
export interface PluginPermissionContribution {
name: string;
titleKey: string;
descriptionKey: string;
/** System roles that should hold this the first time it is seen. */
defaultRoles?: SystemRoleName[];
}
export interface PluginActionContribution {
@@ -125,7 +158,7 @@ export interface PluginContributions {
tabs?: PluginTabContribution[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissionGroup?: PluginPermissionGroup;
permissions?: PluginPermissionContribution[];
/**
* A host-editor checkbox backed by a boolean column on the host record.
* A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker,
@@ -202,7 +235,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"actions",
"actionSlots",
"permissionGroup",
"permissions",
"hostCapability",
]);
@@ -288,7 +321,11 @@ export function validateManifest(manifest: unknown): string[] {
validateRequires(m.requires, errors);
validateProvidesSecret(m.providesSecret, errors);
validateRequiresSecret(m.requiresSecret, errors);
validateContributes(m.contributes, errors);
validateContributes(
m.contributes,
typeof m.id === "string" ? m.id : undefined,
errors,
);
return errors;
}
@@ -511,7 +548,11 @@ function validateRequiresSecret(value: unknown, errors: string[]): void {
});
}
function validateContributes(contributes: unknown, errors: string[]): void {
function validateContributes(
contributes: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (contributes === undefined) return;
if (!isPlainObject(contributes)) {
errors.push('Field "contributes" must be an object');
@@ -520,7 +561,7 @@ function validateContributes(contributes: unknown, errors: string[]): void {
rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors);
validateTabs(contributes.tabs, errors);
validatePermissionGroup(contributes.permissionGroup, errors);
validatePermissions(contributes.permissions, pluginId, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
validateHostCapability(contributes.hostCapability, errors);
@@ -562,74 +603,78 @@ function validateTabs(tabs: unknown, errors: string[]): void {
});
}
function validatePermissionGroup(group: unknown, errors: string[]): void {
if (group === undefined) return;
const where = "contributes.permissionGroup";
if (!isPlainObject(group)) {
errors.push(`${where} must be an object`);
function validatePermissions(
permissions: unknown,
pluginId: string | undefined,
errors: string[],
): void {
if (permissions === undefined) return;
const where = "contributes.permissions";
if (!Array.isArray(permissions)) {
errors.push(`${where} must be an array`);
return;
}
rejectUnknown(
group,
["group", "permissions", "defaultForRole"],
where,
errors,
);
requireString(group.group, `${where}.group`, errors);
const seen = new Set<string>();
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(`${where}.permissions must be a non-empty array`);
} else {
group.permissions.forEach((permission: unknown, index: number) => {
permissions.forEach((entry: unknown, index: number) => {
const at = `${where}[${index}]`;
if (!isPlainObject(entry)) {
errors.push(`${at} must be an object`);
return;
}
rejectUnknown(
entry,
["name", "titleKey", "descriptionKey", "defaultRoles"],
at,
errors,
);
const name = entry.name;
if (typeof name !== "string" || !PERMISSION_NAME_PATTERN.test(name)) {
errors.push(`${at}.name must be a lowercase, dotted permission name`);
} else {
if (seen.has(name)) {
errors.push(`${at}.name "${name}" is declared more than once`);
}
seen.add(name);
// Core groups first: a plugin naming one could gate a route on core
// authority it was never given.
const head = name.split(".")[0];
if ((RESERVED_PERMISSION_PREFIXES as readonly string[]).includes(head)) {
errors.push(
`${at}.name "${name}" starts with the reserved core group "${head}". Permissions are registered as <pluginId>.<name>, so drop the prefix.`,
);
}
// "ai" declaring "ai.use" would register as ai.ai.use, which is always
// a mistake rather than an intent.
if (pluginId && (name === pluginId || head === pluginId)) {
errors.push(
`${at}.name "${name}" already starts with this plugin's id. Permissions are registered as <pluginId>.<name>, so drop the prefix.`,
);
}
}
requireString(entry.titleKey, `${at}.titleKey`, errors);
requireString(entry.descriptionKey, `${at}.descriptionKey`, errors);
if (entry.defaultRoles === undefined) return;
if (!Array.isArray(entry.defaultRoles)) {
errors.push(`${at}.defaultRoles must be an array`);
return;
}
entry.defaultRoles.forEach((role: unknown, roleIndex: number) => {
if (
typeof permission !== "string" ||
!PERMISSION_PATTERN.test(permission)
typeof role !== "string" ||
!(SYSTEM_ROLE_NAMES as readonly string[]).includes(role)
) {
errors.push(
`${where}.permissions[${index}] must be a dotted permission name`,
`${at}.defaultRoles[${roleIndex}] must be one of: ${SYSTEM_ROLE_NAMES.join(", ")}`,
);
}
});
}
if (group.defaultForRole === undefined) return;
if (!isPlainObject(group.defaultForRole)) {
errors.push(
`${where}.defaultForRole must be an object of role -> permissions`,
);
return;
}
const declared = new Set(
Array.isArray(group.permissions)
? (group.permissions as unknown[]).filter(
(value): value is string => typeof value === "string",
)
: [],
);
for (const [role, wanted] of Object.entries(group.defaultForRole)) {
if (!Array.isArray(wanted)) {
errors.push(`${where}.defaultForRole["${role}"] must be an array`);
continue;
}
wanted.forEach((permission: unknown, index: number) => {
if (typeof permission !== "string") {
errors.push(
`${where}.defaultForRole["${role}"][${index}] must be a string`,
);
return;
}
// A plugin may only suggest defaults for permissions it declares itself.
// Without this a manifest could add admin.* to any role at boot.
if (!declared.has(permission)) {
errors.push(
`${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`,
);
}
});
}
});
}
function validateActions(actions: unknown, errors: string[]): void {
@@ -774,8 +819,12 @@ export function parseManifest(raw: unknown): ParsedManifest {
};
}
// Full ids: provides, providesSecret and actions all name permissions the
// way an admin sees them, so they are compared against the qualified form.
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
(manifest.contributes?.permissions ?? []).map((permission) =>
qualifyPermission(manifest.id, permission.name),
),
);
// A permission the catalog never sees is one no admin can grant, so the
@@ -783,21 +832,21 @@ export function parseManifest(raw: unknown): ParsedManifest {
for (const provide of manifest.provides ?? []) {
if (!declared.has(provide.permission)) {
errors.push(
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissionGroup.permissions`,
`provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissions`,
);
}
}
for (const secret of manifest.providesSecret ?? []) {
if (!declared.has(secret.permission)) {
errors.push(
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissionGroup.permissions`,
`providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissions`,
);
}
}
for (const action of manifest.contributes?.actions ?? []) {
if (action.permission && !declared.has(action.permission)) {
errors.push(
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissionGroup.permissions`,
`contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissions`,
);
}
}
+2
View File
@@ -181,6 +181,8 @@ export function createFakeContext(
rbac: {
// Passes everything through: a test asserting a permission gate should
// drive PermissionManager, not this double.
has: async () => true,
hasFor: async () => true,
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
},