mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: add a permission-gated backend service registry for plugins
This commit is contained in:
1 parent
ee2d6b4697
commit
225f618337
17 files changed
+1755
-2
No files matched your search
@@ -771,7 +771,7 @@ function storageKey(raw: unknown): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
function safeDetails(method: string, args: unknown[]): string {
|
||||
export function safeDetails(method: string, args: unknown[]): string {
|
||||
const summary = args.map((arg) => {
|
||||
if (typeof arg === "number" || typeof arg === "boolean") return arg;
|
||||
if (typeof arg === "string") {
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { pluginEvents } from "./events.js";
|
||||
import * as registry from "./registry.js";
|
||||
import * as serviceRegistry from "./service-registry.js";
|
||||
import type { ServiceRegistration } from "./service-registry.js";
|
||||
import { safeDetails } from "./broker.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
export interface InProcessPluginContext {
|
||||
@@ -43,6 +46,18 @@ export interface InProcessPluginContext {
|
||||
consume: <T>(key: string) => T | undefined;
|
||||
revoke: (key: string, value?: unknown) => boolean;
|
||||
};
|
||||
/**
|
||||
* Declared service contracts between plugins. Unlike `registry` above, a
|
||||
* service must be declared in the manifest and every call through a handle
|
||||
* is checked against the acting user's role permissions.
|
||||
*/
|
||||
services: {
|
||||
provide: <T extends object>(service: string, implementation: T) => void;
|
||||
get: <T extends object>(
|
||||
service: string,
|
||||
options?: { userId?: string },
|
||||
) => T;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +71,8 @@ export interface InProcessHandle {
|
||||
unsubscribers: Array<() => void>;
|
||||
/** Registry keys this plugin provided, revoked on deactivate. */
|
||||
providedKeys: Array<{ key: string; value: unknown }>;
|
||||
/** Declared services this plugin provided, revoked on deactivate. */
|
||||
providedServices: ServiceRegistration[];
|
||||
}
|
||||
|
||||
export interface PluginModule {
|
||||
@@ -135,9 +152,76 @@ export function createInProcessContext(
|
||||
consume: (key) => registry.consume(key),
|
||||
revoke: (key, value) => registry.revoke(key, value),
|
||||
},
|
||||
|
||||
services: {
|
||||
provide: (service, implementation) => {
|
||||
// Declared AND provided, the same shape the capability gate uses: a
|
||||
// plugin cannot publish a service its manifest never mentioned.
|
||||
const declared = manifest.provides?.find(
|
||||
(entry) => entry.service === service,
|
||||
);
|
||||
if (!declared) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
|
||||
);
|
||||
}
|
||||
|
||||
const registration = serviceRegistry.provideService({
|
||||
service,
|
||||
version: declared.version,
|
||||
permission: declared.permission,
|
||||
pluginId,
|
||||
pluginName: manifest.name,
|
||||
implementation: implementation as Record<string, unknown>,
|
||||
});
|
||||
handle.providedServices.push(registration);
|
||||
},
|
||||
|
||||
get: (service, options) =>
|
||||
serviceRegistry.createServiceHandle(service, pluginId, {
|
||||
resolveUserId: () => options?.userId,
|
||||
hasPermission: async (userId, permission) => {
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
return PermissionManager.getInstance().hasPermission(
|
||||
userId,
|
||||
permission,
|
||||
);
|
||||
},
|
||||
audit: (entry) => writeServiceAudit(entry),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors PluginBroker.audit: attribution comes from the runtime, never from
|
||||
* the plugin, and the details only ever record the shape of a call.
|
||||
*/
|
||||
async function writeServiceAudit(
|
||||
entry: serviceRegistry.ServiceAuditEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: entry.userId,
|
||||
username: `plugin:${entry.consumerPluginId}`,
|
||||
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
|
||||
resourceType: "plugin",
|
||||
resourceId: entry.registration.pluginId,
|
||||
resourceName: entry.registration.pluginName,
|
||||
details: safeDetails(
|
||||
`${entry.registration.service}.${entry.method}`,
|
||||
entry.args,
|
||||
),
|
||||
success: entry.success,
|
||||
errorMessage: entry.errorMessage,
|
||||
});
|
||||
} catch {
|
||||
// Auditing must never break the caller.
|
||||
}
|
||||
}
|
||||
|
||||
/** Undoes everything the ctx handed out. Safe to call more than once. */
|
||||
export async function disposeInProcessHandle(
|
||||
handle: InProcessHandle,
|
||||
@@ -148,6 +232,13 @@ export async function disposeInProcessHandle(
|
||||
}
|
||||
handle.providedKeys = [];
|
||||
|
||||
// Taking the services with it is what makes a consumer's held handle start
|
||||
// throwing PluginServiceUnavailableError rather than calling a dead provider.
|
||||
for (const registration of handle.providedServices) {
|
||||
serviceRegistry.revokeService(registration.service, registration);
|
||||
}
|
||||
handle.providedServices = [];
|
||||
|
||||
for (const unsubscribe of handle.unsubscribers) {
|
||||
try {
|
||||
unsubscribe();
|
||||
|
||||
@@ -254,11 +254,118 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a plugin's declared permissions into the role catalog.
|
||||
*
|
||||
* Until this ran, contributes.permissionGroup was validated but never
|
||||
* consumed, so a plugin permission could not be granted: PUT /rbac/roles/:id
|
||||
* rejects any string isValidPermission does not know. Registering here is what
|
||||
* makes a plugin permission appear in the admin role editor exactly like
|
||||
* hosts.view does.
|
||||
*/
|
||||
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
|
||||
const group = plugin.manifest.contributes?.permissionGroup;
|
||||
if (!group) return;
|
||||
|
||||
const { registerPermissionGroup } =
|
||||
await import("../utils/permission-catalog.js");
|
||||
registerPermissionGroup({
|
||||
group: group.group,
|
||||
permissions: group.permissions,
|
||||
});
|
||||
|
||||
if (group.defaultForRole) {
|
||||
await applyRoleDefaults(plugin.id, group.defaultForRole);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the plugin's declared role suggestion.
|
||||
*
|
||||
* Additive only, and only for a permission the role does not already list.
|
||||
* An admin's later revoke must never be re-granted on the next restart, which
|
||||
* is the same rule ensureSystemRoles follows for the built-in roles.
|
||||
*/
|
||||
async function applyRoleDefaults(
|
||||
pluginId: string,
|
||||
defaults: Record<string, string[]>,
|
||||
): Promise<void> {
|
||||
const { createCurrentRoleRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { PermissionManager } = await import("../utils/permission-manager.js");
|
||||
const repository = createCurrentRoleRepository();
|
||||
|
||||
for (const [roleName, wanted] of Object.entries(defaults)) {
|
||||
try {
|
||||
const role = await repository.findRoleByName(roleName);
|
||||
if (!role) continue;
|
||||
|
||||
let current: unknown;
|
||||
try {
|
||||
current = role.permissions ? JSON.parse(role.permissions) : [];
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(current)) continue;
|
||||
|
||||
// A wildcard the role already holds covers these, so adding them would
|
||||
// only be noise in the editor.
|
||||
const missing = wanted.filter(
|
||||
(permission) => !coveredBy(current as string[], permission),
|
||||
);
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
await repository.updateRole(role.id, {
|
||||
permissions: JSON.stringify([...(current as string[]), ...missing]),
|
||||
});
|
||||
|
||||
const memberIds = await repository.listRoleUserIds(role.id);
|
||||
for (const memberId of memberIds) {
|
||||
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" },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
ownerUserId?: string,
|
||||
): Promise<void> {
|
||||
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
|
||||
|
||||
// Before activate: a plugin's own activate() may already want to check one
|
||||
// of its permissions.
|
||||
const plugin = pluginLoader.get(pluginId);
|
||||
if (plugin) {
|
||||
try {
|
||||
await registerPluginPermissions(plugin);
|
||||
} catch (error) {
|
||||
pluginLogger.error(
|
||||
`Failed to register permissions for ${pluginId}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
{ operation: "plugin_permissions" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pluginLoader.activate(pluginId, ownerUserId);
|
||||
|
||||
const runtime = pluginBroker.get(pluginId);
|
||||
@@ -267,8 +374,17 @@ export async function activatePlugin(
|
||||
|
||||
export async function deactivatePlugin(pluginId: string): Promise<void> {
|
||||
const { loader: pluginLoader } = getPluginRuntime();
|
||||
const plugin = pluginLoader.get(pluginId);
|
||||
|
||||
await pluginLoader.deactivate(pluginId);
|
||||
unregisterPluginRouter(pluginId);
|
||||
|
||||
const group = plugin?.manifest.contributes?.permissionGroup;
|
||||
if (group) {
|
||||
const { unregisterPermissionGroup } =
|
||||
await import("../utils/permission-catalog.js");
|
||||
unregisterPermissionGroup(group.group);
|
||||
}
|
||||
}
|
||||
|
||||
export async function shutdownPlugins(): Promise<void> {
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
type InProcessHandle,
|
||||
type PluginModule,
|
||||
} from "./host-ctx.js";
|
||||
import { resolveRequirements } from "./service-registry.js";
|
||||
|
||||
export type PluginState =
|
||||
| "loaded"
|
||||
@@ -346,10 +347,25 @@ export class PluginLoader {
|
||||
);
|
||||
}
|
||||
|
||||
// Structural only: is the service present at a satisfying version. A
|
||||
// user's permission is checked per call instead, because activation is
|
||||
// per-instance and permissions are per-user.
|
||||
const resolution = resolveRequirements(plugin.manifest);
|
||||
if (!resolution.satisfied) {
|
||||
throw new Error(`Plugin ${plugin.id} ${resolution.errors.join("; ")}`);
|
||||
}
|
||||
for (const service of resolution.missingOptional) {
|
||||
pluginLogger.info(
|
||||
`Plugin ${plugin.id} optional service "${service}" is not available`,
|
||||
{ operation: "plugin_activate" },
|
||||
);
|
||||
}
|
||||
|
||||
const handle: InProcessHandle = {
|
||||
module: { activate, deactivate },
|
||||
unsubscribers: [],
|
||||
providedKeys: [],
|
||||
providedServices: [],
|
||||
};
|
||||
|
||||
const ctx = createInProcessContext(plugin.manifest, handle);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* runtime, for the same reason. If you change the schema, change both.
|
||||
*/
|
||||
|
||||
import semver from "semver";
|
||||
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
|
||||
|
||||
export const PLUGIN_PERMISSIONS = [
|
||||
@@ -69,6 +70,8 @@ const ID_PATTERN = /^[a-z0-9-]+$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
const API_PREFIX_PATTERN = /^[a-z0-9-]+$/;
|
||||
/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */
|
||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||
|
||||
/**
|
||||
* The plugin SDK major version this build implements. A manifest asking for a
|
||||
@@ -113,9 +116,31 @@ export interface PluginManifest {
|
||||
apiPrefix?: string;
|
||||
};
|
||||
dependencies?: { plugins?: Record<string, string> };
|
||||
/**
|
||||
* Named services this plugin exposes to other plugins. Each one is gated by
|
||||
* its own RBAC permission, which the plugin's own permissionGroup must
|
||||
* declare -- see parseManifest.
|
||||
*/
|
||||
provides?: PluginServiceProvide[];
|
||||
/** Named services this plugin needs another active plugin to provide. */
|
||||
requires?: PluginServiceRequire[];
|
||||
sidecars: Array<{ id: string; binary: string }>;
|
||||
}
|
||||
|
||||
export interface PluginServiceProvide {
|
||||
service: string;
|
||||
version: string;
|
||||
/** The role permission a calling user needs to reach this service. */
|
||||
permission: string;
|
||||
}
|
||||
|
||||
export interface PluginServiceRequire {
|
||||
service: string;
|
||||
versionRange: string;
|
||||
/** When true, an unsatisfied requirement is skipped instead of failing activation. */
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export function validateManifest(manifest: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -209,6 +234,9 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
errors.push('Field "sidecars" must be an array');
|
||||
}
|
||||
|
||||
errors.push(...validateProvides(m.provides));
|
||||
errors.push(...validateRequires(m.requires));
|
||||
|
||||
if (m.contributes && typeof m.contributes === "object") {
|
||||
errors.push(
|
||||
...validateContributes(m.contributes as Record<string, unknown>),
|
||||
@@ -218,6 +246,85 @@ export function validateManifest(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateProvides(provides: unknown): string[] {
|
||||
if (provides === undefined) return [];
|
||||
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
provides.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`provides[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
|
||||
errors.push(
|
||||
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
|
||||
);
|
||||
} else if (seen.has(e.service)) {
|
||||
errors.push(`provides[${index}].service is a duplicate: "${e.service}"`);
|
||||
} else {
|
||||
seen.add(e.service);
|
||||
}
|
||||
|
||||
if (typeof e.version !== "string" || !SEMVER_PATTERN.test(e.version)) {
|
||||
errors.push(
|
||||
`provides[${index}].version must be valid semver, got: "${String(e.version)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof e.permission !== "string" || e.permission.length === 0) {
|
||||
errors.push(`provides[${index}].permission is required`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRequires(requires: unknown): string[] {
|
||||
if (requires === undefined) return [];
|
||||
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
requires.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`requires[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
|
||||
errors.push(
|
||||
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
|
||||
);
|
||||
} else if (seen.has(e.service)) {
|
||||
errors.push(`requires[${index}].service is a duplicate: "${e.service}"`);
|
||||
} else {
|
||||
seen.add(e.service);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof e.versionRange !== "string" ||
|
||||
semver.validRange(e.versionRange) === null
|
||||
) {
|
||||
errors.push(
|
||||
`requires[${index}].versionRange must be a valid semver range, got: "${String(e.versionRange)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if ("optional" in e && typeof e.optional !== "boolean") {
|
||||
errors.push(`requires[${index}].optional must be a boolean`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateContributes(contributes: Record<string, unknown>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
@@ -331,6 +438,21 @@ export function parseManifest(raw: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
// A service permission has to be one the plugin's own permissionGroup
|
||||
// declares, or it would never reach the catalog and every call through it
|
||||
// would deny against a permission no admin can grant.
|
||||
const declaredPermissions =
|
||||
manifest.contributes?.permissionGroup?.permissions ?? [];
|
||||
for (const entry of manifest.provides ?? []) {
|
||||
if (!declaredPermissions.includes(entry.permission)) {
|
||||
return {
|
||||
errors: [
|
||||
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Refused outright rather than ignored: a plugin that asked to run in-process
|
||||
// and was quietly downgraded to a worker would fail later in a confusing way,
|
||||
// and the manifest would still claim reach it does not have.
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Declared, versioned, per-user-permissioned service contracts between plugins.
|
||||
*
|
||||
* This is the typed sibling of registry.ts. That one is a bare key/value map a
|
||||
* plugin can put anything into; this one only carries services a manifest
|
||||
* declared, at a declared version, behind a declared role permission.
|
||||
*
|
||||
* ## What the permission actually gates
|
||||
*
|
||||
* Be precise, because there are two unrelated permission systems in this
|
||||
* runtime and conflating them is the easy mistake:
|
||||
*
|
||||
* - permissions.ts gates what a PLUGIN may do (manifest + admin grant).
|
||||
* - this file gates what a USER may do, through PermissionManager, the same
|
||||
* RBAC check requirePermission() runs on an HTTP route.
|
||||
*
|
||||
* A service permission is an ordinary role permission string. It shows up in
|
||||
* the admin role editor like hosts.view does, and an admin can grant or revoke
|
||||
* it per role or per user at any time, whether or not the consuming plugin is
|
||||
* installed.
|
||||
*
|
||||
* The check runs on every call rather than once when the handle is made. That
|
||||
* is deliberate and is the whole reason the gate lives in the proxy: a revoke
|
||||
* mid-session has to take effect on the next call, with no restart and no
|
||||
* reinstall. PermissionManager's per-user cache is already invalidated by the
|
||||
* RBAC routes that change a role, so "next call" is immediate in practice.
|
||||
*
|
||||
* ## Resolution is structural, permission is per-call
|
||||
*
|
||||
* resolveRequirements only asks "is this service present at a satisfying
|
||||
* version". It deliberately does NOT consult any user's permissions:
|
||||
* activation is per-instance and permissions are per-user, so gating
|
||||
* activation on one user's grants would be meaningless. A user who lacks the
|
||||
* permission gets a clear denial from the call instead of the requiring plugin
|
||||
* mysteriously failing to start.
|
||||
*/
|
||||
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||
|
||||
export interface ServiceRegistration {
|
||||
service: string;
|
||||
version: string;
|
||||
permission: string;
|
||||
/** The plugin that provided it. */
|
||||
pluginId: string;
|
||||
/** Display name, for audit lines. */
|
||||
pluginName: string;
|
||||
implementation: Record<string, unknown>;
|
||||
/**
|
||||
* Bumped on every provide. A handle captures the value it was made against,
|
||||
* so a provider that was revoked and re-registered invalidates old handles
|
||||
* rather than silently rebinding them to a different object.
|
||||
*/
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const services = new Map<string, ServiceRegistration>();
|
||||
/**
|
||||
* What a service looked like before it was revoked.
|
||||
*
|
||||
* A handle taken while the provider was up must keep throwing a named error
|
||||
* after it goes away, rather than degrading to "undefined is not a function",
|
||||
* which would tell a consumer nothing about why its dependency vanished.
|
||||
*/
|
||||
const retired = new Map<string, { pluginId: string; methods: Set<string> }>();
|
||||
let generationCounter = 0;
|
||||
|
||||
/** Thrown when the provider went away while a consumer still held a handle. */
|
||||
export class PluginServiceUnavailableError extends Error {
|
||||
readonly code = "EPLUGINSVCGONE";
|
||||
|
||||
constructor(service: string, providerPluginId: string) {
|
||||
super(
|
||||
`Service "${service}" is no longer provided (was: ${providerPluginId}). ` +
|
||||
`The providing plugin has been disabled or replaced.`,
|
||||
);
|
||||
this.name = "PluginServiceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the acting user lacks the service's permission.
|
||||
*
|
||||
* `status` and `body` are the same 403 shape requirePermission() sends, so a
|
||||
* consumer serving an HTTP request can pass it straight through.
|
||||
*/
|
||||
export class PluginServicePermissionError extends Error {
|
||||
readonly code = "EPLUGINSVCPERM";
|
||||
readonly status = 403;
|
||||
readonly body: { error: string; required: string };
|
||||
|
||||
constructor(service: string, permission: string) {
|
||||
super(
|
||||
`You do not have access to "${service}". It requires the "${permission}" permission.`,
|
||||
);
|
||||
this.name = "PluginServicePermissionError";
|
||||
this.body = { error: "Insufficient permissions", required: permission };
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown when a call cannot be attributed to a user. */
|
||||
export class PluginServiceActorError extends Error {
|
||||
readonly code = "EPLUGINSVCACTOR";
|
||||
|
||||
constructor(service: string) {
|
||||
super(
|
||||
`A call to "${service}" has no acting user. Pass one with ` +
|
||||
`ctx.services.get(name, { userId }) or handle.asUser(userId).`,
|
||||
);
|
||||
this.name = "PluginServiceActorError";
|
||||
}
|
||||
}
|
||||
|
||||
export function provideService(
|
||||
registration: Omit<ServiceRegistration, "generation">,
|
||||
): ServiceRegistration {
|
||||
const existing = services.get(registration.service);
|
||||
if (existing) {
|
||||
pluginLogger.warn(
|
||||
`Service "${registration.service}" is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||
{ operation: "plugin_service_registry" },
|
||||
);
|
||||
}
|
||||
|
||||
const entry: ServiceRegistration = {
|
||||
...registration,
|
||||
generation: ++generationCounter,
|
||||
};
|
||||
services.set(registration.service, entry);
|
||||
retired.delete(registration.service);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a provider, but only the exact registration given. A plugin that
|
||||
* crashed and restarted must not revoke the replacement its own restart
|
||||
* installed, so this is identity-checked rather than by name.
|
||||
*/
|
||||
export function revokeService(
|
||||
service: string,
|
||||
registration?: ServiceRegistration,
|
||||
): boolean {
|
||||
const current = services.get(service);
|
||||
if (!current) return false;
|
||||
if (registration && current.generation !== registration.generation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
retired.set(service, {
|
||||
pluginId: current.pluginId,
|
||||
methods: new Set(
|
||||
Object.keys(current.implementation).filter(
|
||||
(key) => typeof current.implementation[key] === "function",
|
||||
),
|
||||
),
|
||||
});
|
||||
return services.delete(service);
|
||||
}
|
||||
|
||||
export function getRegistration(
|
||||
service: string,
|
||||
): ServiceRegistration | undefined {
|
||||
return services.get(service);
|
||||
}
|
||||
|
||||
export function listServices(): ServiceRegistration[] {
|
||||
return [...services.values()];
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearServiceRegistry(): void {
|
||||
services.clear();
|
||||
retired.clear();
|
||||
}
|
||||
|
||||
export interface RequirementResolution {
|
||||
satisfied: boolean;
|
||||
/** Human-readable reasons for each unsatisfied hard requirement. */
|
||||
errors: string[];
|
||||
/** Optional requirements that are not currently available. */
|
||||
missingOptional: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural resolution of a manifest's `requires` against what is registered
|
||||
* right now. Generic over the service name -- nothing is special-cased.
|
||||
*/
|
||||
export function resolveRequirements(
|
||||
manifest: PluginManifest,
|
||||
): RequirementResolution {
|
||||
const errors: string[] = [];
|
||||
const missingOptional: string[] = [];
|
||||
|
||||
for (const requirement of manifest.requires ?? []) {
|
||||
const registration = services.get(requirement.service);
|
||||
|
||||
if (!registration) {
|
||||
recordUnsatisfied(
|
||||
requirement,
|
||||
`requires service "${requirement.service}" (${requirement.versionRange}), which no active plugin provides`,
|
||||
errors,
|
||||
missingOptional,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!semver.satisfies(registration.version, requirement.versionRange)) {
|
||||
recordUnsatisfied(
|
||||
requirement,
|
||||
`requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`,
|
||||
errors,
|
||||
missingOptional,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { satisfied: errors.length === 0, errors, missingOptional };
|
||||
}
|
||||
|
||||
function recordUnsatisfied(
|
||||
requirement: PluginServiceRequire,
|
||||
message: string,
|
||||
errors: string[],
|
||||
missingOptional: string[],
|
||||
): void {
|
||||
if (requirement.optional) {
|
||||
missingOptional.push(requirement.service);
|
||||
return;
|
||||
}
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
export interface ServiceCallContext {
|
||||
/** Resolves the user this call acts as. */
|
||||
resolveUserId: () => string | undefined;
|
||||
/** Checks a role permission for a user. */
|
||||
hasPermission: (userId: string, permission: string) => Promise<boolean>;
|
||||
/**
|
||||
* Writes the audit line. Must never throw.
|
||||
*
|
||||
* Awaited before the call's own result is handed back, so an action is on
|
||||
* the trail before the caller can act on it. The broker can afford to fire
|
||||
* and forget because it replies over postMessage; here the caller is
|
||||
* holding the promise.
|
||||
*/
|
||||
audit: (entry: ServiceAuditEntry) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ServiceAuditEntry {
|
||||
userId: string;
|
||||
consumerPluginId: string;
|
||||
registration: ServiceRegistration;
|
||||
method: string;
|
||||
args: unknown[];
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a registration in a handle whose every method runs the permission
|
||||
* check before delegating.
|
||||
*
|
||||
* A Proxy rather than a hand-written wrapper so this stays generic over
|
||||
* whatever shape the provider chose to hand out. Only own, callable properties
|
||||
* of that object are reachable -- a consumer can call exactly what the
|
||||
* provider exposed and nothing else. This is a set of individually declared
|
||||
* contracts, not a general "call any method on any plugin" bus.
|
||||
*/
|
||||
export function createServiceHandle<T extends object>(
|
||||
service: string,
|
||||
consumerPluginId: string,
|
||||
context: ServiceCallContext,
|
||||
): T {
|
||||
const target = Object.create(null) as T;
|
||||
|
||||
return new Proxy(target, {
|
||||
get(_target, property) {
|
||||
if (typeof property !== "string") return undefined;
|
||||
|
||||
if (property === "asUser") {
|
||||
return (userId: string) =>
|
||||
createServiceHandle<T>(service, consumerPluginId, {
|
||||
...context,
|
||||
resolveUserId: () => userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolved at call time, not here: the provider may have gone away
|
||||
// between taking the handle and using it. A method that WAS reachable
|
||||
// must keep throwing the typed error rather than degrading to
|
||||
// `undefined is not a function`, which tells a consumer nothing about
|
||||
// why its dependency vanished.
|
||||
const registration = services.get(service);
|
||||
if (!registration) {
|
||||
const gone = retired.get(service);
|
||||
if (!gone?.methods.has(property)) return undefined;
|
||||
return () => {
|
||||
throw new PluginServiceUnavailableError(service, gone.pluginId);
|
||||
};
|
||||
}
|
||||
|
||||
const value = registration.implementation[property];
|
||||
if (typeof value !== "function") return undefined;
|
||||
|
||||
return (...args: unknown[]) =>
|
||||
invokeGuarded(
|
||||
service,
|
||||
property,
|
||||
args,
|
||||
consumerPluginId,
|
||||
context,
|
||||
registration.generation,
|
||||
);
|
||||
},
|
||||
|
||||
has(_target, property) {
|
||||
const registration = services.get(service);
|
||||
if (!registration || typeof property !== "string") return false;
|
||||
return typeof registration.implementation[property] === "function";
|
||||
},
|
||||
|
||||
ownKeys() {
|
||||
const registration = services.get(service);
|
||||
if (!registration) return [];
|
||||
return Object.keys(registration.implementation).filter(
|
||||
(key) => typeof registration.implementation[key] === "function",
|
||||
);
|
||||
},
|
||||
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
const registration = services.get(service);
|
||||
if (!registration || typeof property !== "string") return undefined;
|
||||
if (typeof registration.implementation[property] !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
return { configurable: true, enumerable: true, writable: false };
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
|
||||
async function invokeGuarded(
|
||||
service: string,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
consumerPluginId: string,
|
||||
context: ServiceCallContext,
|
||||
expectedGeneration: number,
|
||||
): Promise<unknown> {
|
||||
const registration = services.get(service);
|
||||
|
||||
// Re-checked here rather than trusted from the get trap: a call is async and
|
||||
// the provider can be disabled between resolving the method and running it.
|
||||
if (!registration || registration.generation !== expectedGeneration) {
|
||||
throw new PluginServiceUnavailableError(
|
||||
service,
|
||||
registration?.pluginId ?? consumerPluginId,
|
||||
);
|
||||
}
|
||||
|
||||
const userId = context.resolveUserId();
|
||||
if (!userId) throw new PluginServiceActorError(service);
|
||||
|
||||
const allowed = await context.hasPermission(userId, registration.permission);
|
||||
if (!allowed) {
|
||||
const error = new PluginServicePermissionError(
|
||||
service,
|
||||
registration.permission,
|
||||
);
|
||||
await context.audit({
|
||||
userId,
|
||||
consumerPluginId,
|
||||
registration,
|
||||
method,
|
||||
args,
|
||||
success: false,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const implementation = registration.implementation[method];
|
||||
if (typeof implementation !== "function") {
|
||||
throw new PluginServiceUnavailableError(service, registration.pluginId);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await (
|
||||
implementation as (...callArgs: unknown[]) => unknown
|
||||
).apply(registration.implementation, args);
|
||||
|
||||
await context.audit({
|
||||
userId,
|
||||
consumerPluginId,
|
||||
registration,
|
||||
method,
|
||||
args,
|
||||
success: true,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
await context.audit({
|
||||
userId,
|
||||
consumerPluginId,
|
||||
registration,
|
||||
method,
|
||||
args,
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user