/** * Composition root for the plugin runtime. * * Owns the loader, seeds the plugins table, keeps capability grants in step * with each manifest, and registers the RBAC permissions a plugin contributes. * * The heavy dependencies are imported lazily inside each function rather than * at module scope: several pull in the repository layer, and a static import * here would close an import cycle through the repositories. */ 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"; import { setSshAuthTypeOwnerSource } from "../hosts/connect/auth-provider-registry.js"; import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js"; import { recordConflict } from "./conflicts.js"; let loader: PluginLoader | null = null; export function getPluginRuntime(): { loader: PluginLoader } { if (!loader) { loader = new PluginLoader(); // The 503-while-disabled answer comes from here rather than from the // router being torn down, so a request that arrives mid-disable gets a // truthful status instead of a 404. setPluginEnabledCheck( (pluginId) => loader?.get(pluginId)?.state === "active", ); // Lets a host whose auth type belongs to a disabled plugin name it. setSshAuthTypeOwnerSource(() => (loader?.list() ?? []).flatMap((plugin) => (plugin.manifest.contributes?.auth?.sshAuthTypes ?? []).map((type) => ({ type, pluginId: plugin.id, pluginName: plugin.manifest.name, })), ), ); // Lets a secret reference whose scheme belongs to a disabled plugin name it. setSecretResolverOwnerSource(() => (loader?.list() ?? []).flatMap((plugin) => (plugin.manifest.contributes?.auth?.secretSchemes ?? []).map( (scheme) => ({ scheme, pluginId: plugin.id, pluginName: plugin.manifest.name, }), ), ), ); } return { loader }; } /** * Gives every discovered plugin a row, and refreshes the stored manifest. * * Bundled plugins start enabled because they are part of the install; a * plugin dropped into the data directory starts disabled, so arriving on disk * is never the same as being allowed to run. * * `state` is only ever set on insert. Once the row exists whatever the user * chose wins, because re-enabling a plugin they disabled on every restart * would be a bug rather than a default. */ async function seedPlugins(loaded: LoadedPlugin[]): Promise { const { createCurrentPluginRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentPluginRepository(); for (const plugin of loaded) { const manifestJson = JSON.stringify(plugin.manifest); const existing = await repository.findById(plugin.id); if (existing) { // Unconditionally, not just on a version change: a manifest edited // without a version bump used to leave the stored copy stale forever, // and the API and the grant check both read the stored copy. await repository.update(plugin.id, { name: plugin.manifest.name, version: plugin.manifest.version, source: plugin.source, manifestJson, }); continue; } await repository.create({ id: plugin.id, name: plugin.manifest.name, version: plugin.manifest.version, tier: plugin.source === "bundled" ? "bundled" : "community", source: plugin.source, state: plugin.source === "bundled" ? "enabled" : "disabled", manifestJson, }); pluginLogger.info(`Registered ${plugin.source} plugin ${plugin.id}`, { operation: "plugin_seed", }); } } /** * Brings plugin_permission_grants in line with the manifests on disk. * * A capability the manifest no longer declares is revoked: leaving it granted * would mean an upgrade silently kept a permission the new version never * asked for. Bundled plugins have every declared capability granted * automatically, since shipping in the install is the consent. */ async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise { const { createCurrentPluginPermissionGrantRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentPluginPermissionGrantRepository(); for (const plugin of loaded) { const declared = new Set(plugin.manifest.capabilities); const existing = await repository.listByPlugin(plugin.id); for (const grant of existing) { if (declared.has(grant.capability)) continue; await repository.revoke(plugin.id, grant.capability); pluginLogger.info( `Revoked ${plugin.id} capability "${grant.capability}": the manifest no longer declares it`, { operation: "plugin_grants" }, ); } if (plugin.source !== "bundled") continue; const granted = new Set(existing.map((grant) => grant.capability)); for (const capability of declared) { if (granted.has(capability)) continue; await repository.grant({ pluginId: plugin.id, capability, grantedBy: null, source: "bundled", }); } invalidatePluginPermissionCache(plugin.id); } } /** * Loads every plugin on disk and activates the ones marked enabled, in * dependency order. Called once from the backend start-up sequence. */ export async function initializePlugins(): Promise { const { loader: pluginLoader } = getPluginRuntime(); const loaded = await pluginLoader.loadAll(); if (loaded.length === 0) return []; await seedPlugins(loaded); await syncCapabilityGrants(loaded); const { createCurrentPluginRepository } = await import("../database/repositories/factory.js"); const records = await createCurrentPluginRepository().listAll(); const enabled = new Set( records .filter((record) => record.state === "enabled") .map((record) => record.id), ); const candidates = loaded .filter((plugin) => enabled.has(plugin.id)) .map((plugin) => plugin.id); for (const pluginId of candidates) { const plugin = pluginLoader.get(pluginId); if (plugin) await registerPluginPermissions(plugin); } const result = await pluginLoader.activateAll(candidates); await persistRuntimeState(loaded); if (result.blocked.size > 0 || result.failed.size > 0) { pluginLogger.warn( `${result.blocked.size} plugin(s) blocked, ${result.failed.size} failed to activate`, { operation: "plugin_init" }, ); } return loaded; } /** Mirrors loader state into the database so the admin API can report it. */ async function persistRuntimeState(loaded: LoadedPlugin[]): Promise { const { createCurrentPluginRepository } = await import("../database/repositories/factory.js"); const repository = createCurrentPluginRepository(); for (const plugin of loaded) { if (plugin.state !== "blocked" && plugin.state !== "failed") continue; try { await repository.update(plugin.id, { state: plugin.state, lastError: plugin.lastError, }); } catch { // Reporting state must never stop the boot. } } } /** * Puts a plugin's declared permissions into the role catalog. * * Each name is registered as `.`, 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 { const declared = plugin.manifest.contributes?.permissions; if (!declared || declared.length === 0) return; try { 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([ ...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)) { recordConflict({ kind: "permission", pluginId: plugin.id, heldBy: coreGroups.has(head) ? "core" : head, name: permission.name, }); pluginLogger.error( `Ignoring ${plugin.id} permission "${permission.name}": it starts with "${head}", which belongs to someone else`, undefined, { operation: "plugin_permissions" }, ); return false; } return true; }); 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}`, error instanceof Error ? error : new Error(String(error)), { operation: "plugin_permissions" }, ); } } /** * Applies a plugin's declared role suggestion, once. * * 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, declared: PluginPermissionContribution[], ): Promise { 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 rbacRepository = createCurrentRbacPermissionRepository(); const alreadyApplied = new Set( (await rbacRepository.listAppliedDefaults()).map( (row) => `${row.roleName}:${row.permission}`, ), ); const byRole = new Map(); 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 recorded: { roleName: string; permission: string }[] = []; for (const [roleName, permissions] of byRole) { try { const role = await roleRepository.findRoleByName(roleName); if (!role) continue; let current: unknown; try { current = role.permissions ? JSON.parse(role.permissions) : []; } catch { continue; } if (!Array.isArray(current)) continue; 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 (alreadyApplied.has(`${roleName}:${permission}`)) return false; return !coveredBy(current as string[], permission); }); for (const permission of permissions) { recorded.push({ roleName, permission }); } if (missing.length === 0) continue; await roleRepository.updateRole(role.id, { permissions: JSON.stringify([...(current as string[]), ...missing]), }); for (const memberId of await roleRepository.listRoleUserIds(role.id)) { PermissionManager.getInstance().invalidateUserPermissionCache(memberId); } } catch (error) { pluginLogger.warn( `Could not apply ${pluginId} role defaults for ${roleName}: ${ error instanceof Error ? error.message : String(error) }`, { operation: "plugin_permissions" }, ); } } try { await rbacRepository.recordAppliedDefaults(recorded); } catch { // Losing the record only means a default may be re-offered once. } } function coveredBy(permissions: string[], permission: string): boolean { if (permissions.includes("*") || permissions.includes(permission)) { return true; } const parts = permission.split("."); for (let i = parts.length; i > 0; i--) { if (permissions.includes(`${parts.slice(0, i).join(".")}.*`)) return true; } return false; } export async function activatePlugin(pluginId: string): Promise { const { loader: pluginLoader } = getPluginRuntime(); // Before activate: a plugin's own activate() may already want to check one // of its permissions. const plugin = pluginLoader.get(pluginId); if (plugin) await registerPluginPermissions(plugin); await pluginLoader.activate(pluginId); // A plugin enabled without a restart still needs its 2.8 data copied. const { runPluginDataMigrations } = await import("../upgrade/boot-migrations.js"); await runPluginDataMigrations(); } export async function deactivatePlugin(pluginId: string): Promise { const { loader: pluginLoader } = getPluginRuntime(); const plugin = pluginLoader.get(pluginId); await pluginLoader.deactivate(pluginId); unregisterPluginHttp(pluginId); // 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"); markPluginPermissionsDisabled(pluginId); } } export async function shutdownPlugins(): Promise { if (!loader) return; for (const plugin of loader.list()) unregisterPluginHttp(plugin.id); await loader.shutdown(); } /** Test seam. */ export function resetPluginRuntime(): void { loader = null; }