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

+15
View File
@@ -35,6 +35,7 @@ import {
unregisterPluginHttp,
} from "./http.js";
import { registerPluginWsRoute, registerPluginWsUpgrade } from "./ws.js";
import { resolvePermission } from "./rbac.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import {
PluginCapabilityError,
@@ -474,6 +475,20 @@ export function createPluginContext(
},
rbac: {
// A short name takes this plugin's prefix; another plugin's id or a core
// group is used as given, which is what makes a cross-plugin check
// expressible without letting a plugin gate its own routes on it.
has: async (permission) => {
const actor = getActor();
if (!actor) return false;
return checkPermission(actor, resolvePermission(manifest, permission));
},
hasFor: async (userId, permission) => {
if (typeof userId !== "string" || userId.length === 0) return false;
return checkPermission(userId, resolvePermission(manifest, permission));
},
require: (permission) => createRbacMiddleware(manifest, permission),
},
+11 -8
View File
@@ -30,6 +30,7 @@ import { pluginLogger } from "../utils/logger.js";
import { AuthManager } from "../utils/auth-manager.js";
import { runAsActor } from "./actor.js";
import { hasCapability } from "./permissions.js";
import { declaredPermissions, resolvePermission } from "./rbac.js";
/** Matches what core's own routes accept, so moving a route changes nothing. */
const DEFAULT_BODY_LIMIT = "2mb";
@@ -272,18 +273,20 @@ async function writePublicRouteAudit(
* route could require admin.users.manage and borrow someone else's authority,
* which is the escalation shape the manifest validator already blocks for
* role defaults.
*
* The 401/403 bodies match requirePermission(), so a plugin route denies the
* same way a core route does and a client needs one code path.
*/
export function createRbacMiddleware(
manifest: PluginManifest,
permission: string,
): PluginMiddleware {
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
);
const required = resolvePermission(manifest, permission);
const declared = declaredPermissions(manifest);
if (!declared.has(permission)) {
if (!declared.has(required)) {
throw new Error(
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissionGroup.permissions`,
`Plugin ${manifest.id} cannot require permission "${permission}": it is not declared in contributes.permissions`,
);
}
@@ -291,7 +294,7 @@ export function createRbacMiddleware(
void (async () => {
const userId = (req as Request & { userId?: string }).userId;
if (!userId) {
res.status(401).json({ error: "Authentication required" });
res.status(401).json({ error: "Not authenticated" });
return;
}
@@ -300,10 +303,10 @@ export function createRbacMiddleware(
await import("../utils/permission-manager.js");
const allowed = await PermissionManager.getInstance().hasPermission(
userId,
permission,
required,
);
if (!allowed) {
res.status(403).json({ error: "Permission denied", permission });
res.status(403).json({ error: "Insufficient permissions", required });
return;
}
next();
+122 -58
View File
@@ -12,6 +12,7 @@
import { pluginLogger } from "../utils/logger.js";
import { setPluginEnabledCheck, unregisterPluginHttp } from "./http.js";
import { PluginLoader, type LoadedPlugin } from "./loader.js";
import type { PluginPermissionContribution } from "./manifest.js";
import { invalidatePluginPermissionCache } from "./permissions.js";
let loader: PluginLoader | null = null;
@@ -187,26 +188,91 @@ async function persistRuntimeState(loaded: LoadedPlugin[]): Promise<void> {
/**
* Puts a plugin's declared permissions into the role catalog.
*
* Until this runs a plugin permission cannot be granted at all, because
* PUT /rbac/roles/:id rejects any string isValidPermission does not know.
* Registering here is what makes one appear in the admin role editor exactly
* like hosts.view does.
* Each name is registered as `<pluginId>.<name>`, so a plugin cannot claim a
* core group or another plugin's namespace. Until this runs a plugin permission
* cannot be granted at all, because PUT /rbac/roles/:id rejects any string
* isValidPermission does not know.
*
* The qualified ids are also written to rbac_known_permissions, which is what
* keeps a role holding one valid after the plugin is disabled or removed.
*/
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
const group = plugin.manifest.contributes?.permissionGroup;
if (!group) return;
const declared = plugin.manifest.contributes?.permissions;
if (!declared || declared.length === 0) return;
try {
const { registerPermissionGroup } =
await import("../utils/permission-catalog.js");
registerPermissionGroup({
group: group.group,
permissions: group.permissions,
const {
registerPluginPermissions: register,
PERMISSION_CATALOG,
getPermissionCatalog,
rememberPermissions,
} = await import("../utils/permission-catalog.js");
const { qualifyPermission, RESERVED_PERMISSION_PREFIXES } =
await import("./manifest.js");
// The manifest validator blocks core groups and the plugin's own id, but
// it cannot know which other plugins exist. Re-checked here because the
// stored manifest is data.
const coreGroups = new Set<string>([
...RESERVED_PERMISSION_PREFIXES,
...PERMISSION_CATALOG.map((entry) => entry.group),
]);
// Both the loaded plugins and any namespace already in the catalog: a
// disabled plugin still owns its permissions, so claiming them while it is
// off would let the namespace change hands.
const otherPluginIds = new Set(
[
...getPluginRuntime()
.loader.list()
.map((candidate) => candidate.id),
...getPermissionCatalog()
.filter((entry) => entry.pluginId !== undefined)
.map((entry) => entry.group),
].filter((id) => id !== plugin.id),
);
const accepted = declared.filter((permission) => {
const head = permission.name.split(".")[0];
if (coreGroups.has(head) || otherPluginIds.has(head)) {
pluginLogger.warn(
`Ignoring ${plugin.id} permission "${permission.name}": it starts with "${head}", which belongs to someone else`,
{ operation: "plugin_permissions" },
);
return false;
}
return true;
});
if (group.defaultForRole) {
await applyRoleDefaults(plugin.id, group);
}
if (accepted.length === 0) return;
const items = accepted.map((permission) => ({
permission: qualifyPermission(plugin.id, permission.name),
titleKey: permission.titleKey,
descriptionKey: permission.descriptionKey,
}));
register({
group: plugin.id,
pluginId: plugin.id,
label: plugin.manifest.name,
icon: plugin.manifest.icon,
enabled: true,
permissions: items.map((item) => item.permission),
items,
});
rememberPermissions(items.map((item) => item.permission));
const { createCurrentRbacPermissionRepository } =
await import("../database/repositories/factory.js");
await createCurrentRbacPermissionRepository().recordKnown(
items.map((item) => ({
permission: item.permission,
pluginId: plugin.id,
})),
);
await applyRoleDefaults(plugin.id, accepted);
} catch (error) {
pluginLogger.error(
`Failed to register permissions for ${plugin.id}`,
@@ -219,44 +285,49 @@ async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
/**
* Applies a plugin's declared role suggestion, once.
*
* Only permissions the plugin declares itself are eligible; the manifest
* validator enforces that, and this re-checks it because the stored manifest
* is data. Without it a manifest could add admin.* to any role at boot.
*
* Each default is applied at most once and the fact is recorded, so an admin
* who later revokes it does not get it handed back on the next restart. The
* old code claimed that behaviour in a comment but re-added the permission
* every time.
* Only the seeded system roles are eligible, and only permissions the plugin
* declares itself. Each default is applied at most once and the fact is
* recorded in rbac_applied_defaults, so an admin who later revokes it does not
* get it handed back on the next restart. That ledger lives in core rather than
* in plugin_storage, which cascades with the plugin: uninstall-then-reinstall
* used to silently re-add a permission that had been deliberately removed.
*/
async function applyRoleDefaults(
pluginId: string,
group: {
group: string;
permissions: string[];
defaultForRole?: Record<string, string[]>;
},
declared: PluginPermissionContribution[],
): Promise<void> {
const { createCurrentRoleRepository, createCurrentPluginStorageRepository } =
const wanted = declared.filter(
(permission) => (permission.defaultRoles ?? []).length > 0,
);
if (wanted.length === 0) return;
const { createCurrentRoleRepository, createCurrentRbacPermissionRepository } =
await import("../database/repositories/factory.js");
const { PermissionManager } = await import("../utils/permission-manager.js");
const { qualifyPermission, SYSTEM_ROLE_NAMES } =
await import("./manifest.js");
const roleRepository = createCurrentRoleRepository();
const storage = createCurrentPluginStorageRepository();
const rbacRepository = createCurrentRbacPermissionRepository();
const APPLIED_KEY = "__role_defaults_applied";
let applied: string[] = [];
try {
const raw = await storage.get(pluginId, APPLIED_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) applied = parsed;
} catch {
applied = [];
const alreadyApplied = new Set(
(await rbacRepository.listAppliedDefaults()).map(
(row) => `${row.roleName}:${row.permission}`,
),
);
const byRole = new Map<string, string[]>();
for (const permission of wanted) {
for (const role of permission.defaultRoles ?? []) {
if (!(SYSTEM_ROLE_NAMES as readonly string[]).includes(role)) continue;
const id = qualifyPermission(pluginId, permission.name);
byRole.set(role, [...(byRole.get(role) ?? []), id]);
}
}
const declared = new Set(group.permissions);
const appliedSet = new Set(applied);
const recorded: { roleName: string; permission: string }[] = [];
for (const [roleName, wanted] of Object.entries(group.defaultForRole ?? {})) {
for (const [roleName, permissions] of byRole) {
try {
const role = await roleRepository.findRoleByName(roleName);
if (!role) continue;
@@ -269,24 +340,15 @@ async function applyRoleDefaults(
}
if (!Array.isArray(current)) continue;
const missing = wanted.filter((permission) => {
if (!declared.has(permission)) {
pluginLogger.warn(
`Ignoring ${pluginId} role default "${permission}" for ${roleName}: the plugin does not declare it`,
{ operation: "plugin_permissions" },
);
return false;
}
const missing = permissions.filter((permission) => {
// Applied before means the admin has had the chance to remove it, so
// its absence now is a decision rather than a gap.
if (appliedSet.has(`${roleName}:${permission}`)) return false;
if (alreadyApplied.has(`${roleName}:${permission}`)) return false;
return !coveredBy(current as string[], permission);
});
for (const permission of wanted) {
if (declared.has(permission)) {
appliedSet.add(`${roleName}:${permission}`);
}
for (const permission of permissions) {
recorded.push({ roleName, permission });
}
if (missing.length === 0) continue;
@@ -309,7 +371,7 @@ async function applyRoleDefaults(
}
try {
await storage.set(pluginId, APPLIED_KEY, JSON.stringify([...appliedSet]));
await rbacRepository.recordAppliedDefaults(recorded);
} catch {
// Losing the record only means a default may be re-offered once.
}
@@ -344,11 +406,13 @@ export async function deactivatePlugin(pluginId: string): Promise<void> {
await pluginLoader.deactivate(pluginId);
unregisterPluginHttp(pluginId);
const group = plugin?.manifest.contributes?.permissionGroup;
if (group) {
const { unregisterPermissionGroup } =
// The group stays in the catalog, greyed: a role holding one of these is not
// wrong just because the plugin is off, and removing them made every role
// that held one unsaveable.
if (plugin?.manifest.contributes?.permissions?.length) {
const { markPluginPermissionsDisabled } =
await import("../utils/permission-catalog.js");
unregisterPermissionGroup(group.group);
markPluginPermissionsDisabled(pluginId);
}
}
+5 -1
View File
@@ -17,6 +17,9 @@ export {
DEFAULT_BACKEND_ENTRY,
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
SYSTEM_ROLE_NAMES,
RESERVED_PERMISSION_PREFIXES,
qualifyPermission,
} from "@termix/plugin-sdk/manifest";
export type {
@@ -27,7 +30,8 @@ export type {
PluginTabContribution,
PluginActionContribution,
PluginActionSlot,
PluginPermissionGroup,
PluginPermissionContribution,
SystemRoleName,
PluginServiceProvide,
PluginServiceRequire,
PluginSecretProvide,
+54
View File
@@ -0,0 +1,54 @@
/**
* How a plugin names a core role permission.
*
* A plugin writes its own permissions short ("services.use") and core registers
* them as `<pluginId>.<name>`. A cross-plugin check still has to be
* expressible, so a string that already names another plugin or a core group is
* taken as given. Everything ctx.rbac exposes, and the per-route gate in
* http.ts, resolves through here so the four paths cannot disagree.
*/
import {
PERMISSION_CATALOG,
getPermissionCatalog,
} from "../utils/permission-catalog.js";
import type { PluginManifest } from "./manifest.js";
function coreGroups(): Set<string> {
return new Set(PERMISSION_CATALOG.map((entry) => entry.group));
}
/** The ids this plugin declares, in their registered `<pluginId>.<name>` form. */
export function declaredPermissions(manifest: PluginManifest): Set<string> {
return new Set(
(manifest.contributes?.permissions ?? []).map(
(permission) => `${manifest.id}.${permission.name}`,
),
);
}
/**
* Turns whatever a plugin passed into the id core stores.
*
* A bare name takes this plugin's prefix. A string whose first segment is a
* core group, or this plugin's own id, is already a full id.
*/
export function resolvePermission(
manifest: PluginManifest,
permission: string,
): string {
const head = permission.split(".")[0];
if (!head) return permission;
if (head === manifest.id) return permission;
if (coreGroups().has(head)) return permission;
// Another plugin's registered group, which is the cross-plugin check.
const otherPlugin = getPermissionCatalog().some(
(entry) => entry.pluginId !== undefined && entry.group === head,
);
if (otherPlugin) return permission;
// Nothing claims that first segment, so it is a short name of our own.
return `${manifest.id}.${permission}`;
}