763 lines
30 KiB
TypeScript
763 lines
30 KiB
TypeScript
/**
|
|
* Phase 06 #22 — Plugin lifecycle service.
|
|
*
|
|
* Owns install / enable / disable / list operations against the
|
|
* Plugin Prisma table created in #21. Each mutating call writes an
|
|
* audit-event row through the existing `createAuditEvent` helper so
|
|
* tenant admins have a tamper-evident trail of who installed or
|
|
* toggled what.
|
|
*
|
|
* Foundation scope:
|
|
* - install({ tenantId, pluginId, version, manifest, wasmSha256,
|
|
* signaturePublicKey, actor }): create row + audit.
|
|
* - enable / disable: flip Plugin.enabled + audit.
|
|
* - list({ tenantId }): page through installed plugins.
|
|
* - uninstall: delete row + audit (does NOT delete the .wasm binary
|
|
* — operator-only step, deferred to #23 marketplace work).
|
|
*
|
|
* Deferred (see hooks-lifecycle.md):
|
|
* - capability consent dialog flow (we trust the manifest's
|
|
* declared capabilities for the foundation).
|
|
* - per-tenant secret + settings tables.
|
|
* - hot-reload + side-by-side version updates.
|
|
*/
|
|
|
|
import { db } from "~/utils/db.server";
|
|
import {
|
|
isPluginCompatibleWithHost,
|
|
isCapabilityGrantScopeSatisfied,
|
|
parsePluginManifest,
|
|
parseTrustedPublicKeys,
|
|
verifyPluginSignature,
|
|
assertPluginRuntimeProductionEnabled,
|
|
getUnavailablePluginCapabilityNames,
|
|
getUnavailablePluginHookNames,
|
|
getPluginHook,
|
|
type PluginManifest,
|
|
type PluginVersionConstraints,
|
|
} from "@toolctrl/plugin-runtime";
|
|
import { createAuditEvent, publishAuditEventViaOutbox } from "@toolctrl/db/audit-events.server";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
import rootPackageJson from "../../../../../../../package.json" with { type: "json" };
|
|
import { getPluginConsentRequirements, isPluginHookConsentName } from "../../utils/pluginConsentRequirements";
|
|
|
|
const logger = createScopedLogger("plugin-lifecycle");
|
|
|
|
export { isCapabilityGrantScopeSatisfied };
|
|
|
|
/**
|
|
* Host version used by the install-time compat check (Phase 06 #23
|
|
* Step 12). Reads from `PLUGIN_HOST_VERSION_OVERRIDE` first so operators
|
|
* can pin a value during incident response without re-tagging the
|
|
* monorepo, falls back to `npm_package_version` (set by pnpm at run
|
|
* time), then to the literal version baked into `package.json` at
|
|
* build time.
|
|
*/
|
|
function resolveHostVersion(): string {
|
|
return process.env.PLUGIN_HOST_VERSION_OVERRIDE || process.env.npm_package_version || (rootPackageJson as { version: string }).version;
|
|
}
|
|
|
|
/**
|
|
* Pull `minHostVersion` / `maxHostVersion` off the raw manifest input.
|
|
* The Zod schema strips unknown keys via `.strict()`, so we read the
|
|
* fields off the original object before the parse step would discard
|
|
* them. Returns an empty object when the input is not a plain object
|
|
* or carries neither field.
|
|
*/
|
|
function extractVersionConstraints(raw: unknown): PluginVersionConstraints {
|
|
if (!raw || typeof raw !== "object") return {};
|
|
const obj = raw as Record<string, unknown>;
|
|
const out: PluginVersionConstraints = {};
|
|
if (typeof obj.minHostVersion === "string") out.minHostVersion = obj.minHostVersion;
|
|
if (typeof obj.maxHostVersion === "string") out.maxHostVersion = obj.maxHostVersion;
|
|
return out;
|
|
}
|
|
|
|
export class PluginLifecycleError extends Error {
|
|
public override readonly name = "PluginLifecycleError";
|
|
public readonly code:
|
|
| "manifest_invalid"
|
|
| "already_installed"
|
|
| "not_found"
|
|
| "incompatible_host_version"
|
|
| "signature_key_not_trusted"
|
|
| "signature_key_mismatch"
|
|
| "tenant_install_limit_exceeded"
|
|
| "binary_not_found"
|
|
| "binary_verification_failed"
|
|
| "capability_consent_missing"
|
|
| "production_guard_violation"
|
|
| "capability_unavailable"
|
|
| "hook_unavailable"
|
|
| "capability_not_in_manifest"
|
|
| "capability_scope_invalid";
|
|
constructor(code: PluginLifecycleError["code"], message: string) {
|
|
super(message);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* F-005 Phase B — install-time binary signature verification.
|
|
*
|
|
* Phase A (already shipped) gates installs on the trust-store and on
|
|
* `signedBy === signaturePublicKey`. Phase B closes the remaining gap:
|
|
* we now also read the .wasm binary from disk and run the same
|
|
* `verifyPluginSignature` the worker invokes per-call. If the binary
|
|
* doesn't exist, has the wrong sha256, or fails Ed25519 verification,
|
|
* we reject the install — the operator gets a synchronous, descriptive
|
|
* error instead of learning about the mismatch via auto-disable noise.
|
|
*
|
|
* The binary base path mirrors the worker bootstrap convention; the
|
|
* operator pre-positions the binary at
|
|
* <PLUGIN_BINARY_BASE_PATH>/<pluginId>/<version>/plugin.wasm
|
|
* before triggering the install API. Dev overrides via
|
|
* `PLUGIN_BINARY_BASE_PATH`; default `/app/var/plugins`.
|
|
*/
|
|
const DEFAULT_PLUGIN_BINARY_BASE_PATH = "/app/var/plugins";
|
|
|
|
function isProductionRuntime() {
|
|
return process.env.NODE_ENV === "production";
|
|
}
|
|
|
|
function assertPluginProductionEscapeHatchesDisabled() {
|
|
if (!isProductionRuntime()) {
|
|
return;
|
|
}
|
|
if (process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY === "true") {
|
|
throw new PluginLifecycleError("production_guard_violation", "PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true is not allowed in production.");
|
|
}
|
|
if (process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT === "false") {
|
|
throw new PluginLifecycleError("production_guard_violation", "PLUGIN_REQUIRE_CAPABILITY_CONSENT=false is not allowed in production.");
|
|
}
|
|
}
|
|
|
|
function assertPluginProductionRuntimeEnabled(operation: string) {
|
|
try {
|
|
assertPluginRuntimeProductionEnabled();
|
|
} catch (error) {
|
|
throw new PluginLifecycleError("production_guard_violation", `${operation} blocked: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
function assertProductionCapabilitiesAvailable(operation: string, capabilities: ReadonlyArray<PluginManifest["capabilities"][number] | string>) {
|
|
if (!isProductionRuntime()) {
|
|
return;
|
|
}
|
|
const unavailable = getUnavailablePluginCapabilityNames(capabilities);
|
|
if (unavailable.length === 0) {
|
|
return;
|
|
}
|
|
throw new PluginLifecycleError(
|
|
"capability_unavailable",
|
|
`${operation} blocked: capabilities [${unavailable.join(", ")}] do not have reviewed production host bindings yet.`
|
|
);
|
|
}
|
|
|
|
function assertProductionHooksAvailable(operation: string, hooks: ReadonlyArray<PluginManifest["hooks"][number] | string>) {
|
|
if (!isProductionRuntime()) {
|
|
return;
|
|
}
|
|
const unavailable = getUnavailablePluginHookNames(hooks);
|
|
if (unavailable.length === 0) {
|
|
return;
|
|
}
|
|
throw new PluginLifecycleError(
|
|
"hook_unavailable",
|
|
`${operation} blocked: hooks [${unavailable.join(", ")}] are registered but not reviewed for production availability yet.`
|
|
);
|
|
}
|
|
|
|
function assertManifestHooksRegistered(manifest: PluginManifest) {
|
|
const unknownHook = manifest.hooks.find((hook) => !getPluginHook(hook.name));
|
|
if (unknownHook) {
|
|
throw new PluginLifecycleError(
|
|
"manifest_invalid",
|
|
`Plugin ${manifest.id}@${manifest.version} declares unknown hook '${unknownHook.name}'. Register the hook in the host runtime before installing.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseStoredPluginManifest(manifestJson: unknown, context: { pluginId?: string; version?: string }): PluginManifest {
|
|
const parsed = parsePluginManifest(manifestJson);
|
|
if (parsed.ok) return parsed.manifest;
|
|
const label = context.pluginId && context.version ? ` for ${context.pluginId}@${context.version}` : "";
|
|
throw new PluginLifecycleError("manifest_invalid", `Stored plugin manifest${label} failed validation: ${JSON.stringify(parsed.issues)}`);
|
|
}
|
|
|
|
async function readPluginBinary(args: { basePath: string; pluginId: string; version: string }): Promise<Uint8Array | null> {
|
|
const { promises: fs } = await import("node:fs");
|
|
const path = await import("node:path");
|
|
const filePath = path.join(args.basePath, args.pluginId, args.version, "plugin.wasm");
|
|
try {
|
|
const buf = await fs.readFile(filePath);
|
|
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* F-009 — per-tenant install cap. Without this, a tenant admin can
|
|
* install thousands of plugin rows to bloat the Plugin table and the
|
|
* hook fan-out path. The default is conservative (50); operators
|
|
* override via `PLUGIN_MAX_INSTALLS_PER_TENANT`. A non-positive override
|
|
* disables the cap so dev environments can opt out.
|
|
*/
|
|
const DEFAULT_MAX_INSTALLS_PER_TENANT = 50;
|
|
|
|
function resolveMaxInstallsPerTenant(): number {
|
|
const raw = process.env.PLUGIN_MAX_INSTALLS_PER_TENANT;
|
|
if (typeof raw !== "string" || raw.length === 0) return DEFAULT_MAX_INSTALLS_PER_TENANT;
|
|
const parsed = Number.parseInt(raw, 10);
|
|
if (!Number.isFinite(parsed)) return DEFAULT_MAX_INSTALLS_PER_TENANT;
|
|
if (parsed <= 0) return Number.POSITIVE_INFINITY; // 0 or negative disables the cap
|
|
return parsed;
|
|
}
|
|
|
|
export type PluginLifecycleActor = {
|
|
userId: string | null;
|
|
email: string | null;
|
|
ip: string | null;
|
|
};
|
|
|
|
export type InstallPluginInput = {
|
|
tenantId: string;
|
|
manifestJson: unknown;
|
|
wasmSha256: string;
|
|
signaturePublicKey: string;
|
|
actor: PluginLifecycleActor;
|
|
};
|
|
|
|
export type PluginListItem = {
|
|
id: string;
|
|
pluginId: string;
|
|
version: string;
|
|
enabled: boolean;
|
|
manifest: PluginManifest;
|
|
installedAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
export async function installPlugin(input: InstallPluginInput): Promise<PluginListItem> {
|
|
assertPluginProductionRuntimeEnabled("Plugin install");
|
|
assertPluginProductionEscapeHatchesDisabled();
|
|
const parsed = parsePluginManifest(input.manifestJson);
|
|
if (!parsed.ok) {
|
|
throw new PluginLifecycleError("manifest_invalid", `Manifest validation failed: ${JSON.stringify(parsed.issues)}`);
|
|
}
|
|
const manifest = parsed.manifest;
|
|
assertManifestHooksRegistered(manifest);
|
|
assertProductionHooksAvailable("Plugin install", manifest.hooks ?? []);
|
|
assertProductionCapabilitiesAvailable("Plugin install", manifest.capabilities ?? []);
|
|
|
|
// Host-version compatibility check (Phase 06 #23 Step 12). The Zod
|
|
// schema does not yet carry `minHostVersion` / `maxHostVersion` —
|
|
// they ride on the raw manifest object when the plugin author opts
|
|
// in, and we read them off the original input so the strict-parse
|
|
// step does not strip them before the check runs.
|
|
const rawConstraints = extractVersionConstraints(input.manifestJson);
|
|
const hostVersion = resolveHostVersion();
|
|
const compat = isPluginCompatibleWithHost({ ...manifest, ...rawConstraints }, hostVersion);
|
|
if (!compat.compatible) {
|
|
throw new PluginLifecycleError("incompatible_host_version", `Plugin ${manifest.id}@${manifest.version} rejected: ${compat.reason} — ${compat.details}`);
|
|
}
|
|
|
|
// F-005 Phase A — install-time trust-store gate. The worker re-verifies
|
|
// the binary signature at every invocation, but we should not let a
|
|
// tenant admin pin a Plugin row to a publisher key the operator has
|
|
// not trusted: doing so pollutes the table with rows that will always
|
|
// fail to invoke and could mislead the audit trail. We also require
|
|
// that the submitted `signaturePublicKey` matches the manifest's
|
|
// `signedBy` field — anything else means the caller sent inconsistent
|
|
// metadata and we reject up front.
|
|
//
|
|
// Phase B (full binary signature verify on install — i.e. running
|
|
// `verifyPluginSignature` against the actual .wasm on disk before
|
|
// creating the row) still depends on the operator-side binary upload
|
|
// pipeline and is tracked in plugin-internal-findings.md F-005.
|
|
const submittedKey = input.signaturePublicKey.toLowerCase();
|
|
if (manifest.signedBy.toLowerCase() !== submittedKey) {
|
|
throw new PluginLifecycleError("signature_key_mismatch", `signaturePublicKey does not match manifest.signedBy for plugin ${manifest.id}.`);
|
|
}
|
|
const trustedKeys = parseTrustedPublicKeys(process.env.PLUGIN_TRUSTED_PUBLIC_KEYS);
|
|
if (!trustedKeys.includes(submittedKey)) {
|
|
throw new PluginLifecycleError(
|
|
"signature_key_not_trusted",
|
|
`Publisher key ${submittedKey.slice(0, 16)}… is not in PLUGIN_TRUSTED_PUBLIC_KEYS — install rejected.`
|
|
);
|
|
}
|
|
|
|
// F-005 Phase B — verify the .wasm binary against the manifest BEFORE
|
|
// creating the Plugin row. This catches sha256/signature mismatches at
|
|
// install time instead of letting the worker discover them at invoke
|
|
// (where they would surface as repeated `signature_invalid` failures
|
|
// and trip the crash tracker).
|
|
//
|
|
// Skipped intentionally when PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true so
|
|
// dev environments without binaries on disk can still run integration
|
|
// tests. The flag must NOT be set in production — documented in the
|
|
// operator runbook.
|
|
if (process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY !== "true") {
|
|
const basePath = process.env.PLUGIN_BINARY_BASE_PATH?.trim() || DEFAULT_PLUGIN_BINARY_BASE_PATH;
|
|
const binary = await readPluginBinary({
|
|
basePath,
|
|
pluginId: manifest.id,
|
|
version: manifest.version,
|
|
});
|
|
if (!binary) {
|
|
throw new PluginLifecycleError(
|
|
"binary_not_found",
|
|
`Plugin binary for ${manifest.id}@${manifest.version} not found at ${basePath}. ` +
|
|
"Operator must pre-position the .wasm file before triggering install."
|
|
);
|
|
}
|
|
const verifyResult = verifyPluginSignature({
|
|
wasmBuffer: binary,
|
|
expectedWasmSha256: input.wasmSha256,
|
|
signatureHex: manifest.signature,
|
|
publicKeyHex: manifest.signedBy,
|
|
trustedPublicKeysHex: trustedKeys,
|
|
manifest,
|
|
});
|
|
if (!verifyResult.ok) {
|
|
throw new PluginLifecycleError(
|
|
"binary_verification_failed",
|
|
`Plugin binary verification failed for ${manifest.id}@${manifest.version}: ${verifyResult.reason}.`
|
|
);
|
|
}
|
|
// Cross-check submitted wasmSha256 matches manifest.wasmSha256.
|
|
if (input.wasmSha256.toLowerCase() !== manifest.wasmSha256.toLowerCase()) {
|
|
throw new PluginLifecycleError(
|
|
"binary_verification_failed",
|
|
`Submitted wasmSha256 does not match manifest.wasmSha256 for ${manifest.id}@${manifest.version}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
const existing = await db.plugin.findUnique({
|
|
where: { tenantId_pluginId: { tenantId: input.tenantId, pluginId: manifest.id } },
|
|
});
|
|
if (existing) {
|
|
throw new PluginLifecycleError("already_installed", `Plugin ${manifest.id} is already installed for tenant ${input.tenantId}.`);
|
|
}
|
|
|
|
// F-009 — enforce per-tenant install cap. We count only AFTER all
|
|
// cheaper rejections so an over-limit tenant still gets the most
|
|
// useful error (e.g. signature_key_not_trusted) when they submit
|
|
// garbage. The count + create are not transactional — racy double-
|
|
// install can squeak by once at the boundary, but the unique
|
|
// (tenantId, pluginId) index above rejects exact-duplicate ids, so
|
|
// the worst case is a tenant landing at `cap + 1` distinct plugins.
|
|
const maxInstalls = resolveMaxInstallsPerTenant();
|
|
if (Number.isFinite(maxInstalls)) {
|
|
const installed = await db.plugin.count({ where: { tenantId: input.tenantId } });
|
|
if (installed >= maxInstalls) {
|
|
throw new PluginLifecycleError(
|
|
"tenant_install_limit_exceeded",
|
|
`Tenant ${input.tenantId} has reached the install cap (${installed}/${maxInstalls}). ` +
|
|
"Uninstall an existing plugin or raise PLUGIN_MAX_INSTALLS_PER_TENANT."
|
|
);
|
|
}
|
|
}
|
|
|
|
const row = await db.plugin.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
pluginId: manifest.id,
|
|
version: manifest.version,
|
|
manifest: manifest as unknown as Parameters<typeof db.plugin.create>[0]["data"]["manifest"],
|
|
wasmSha256: input.wasmSha256,
|
|
signaturePublicKey: input.signaturePublicKey,
|
|
enabled: false,
|
|
},
|
|
});
|
|
|
|
await createAuditEvent({
|
|
tenantId: input.tenantId,
|
|
actorId: input.actor.userId,
|
|
actorType: input.actor.userId ? "user" : "system",
|
|
actorEmail: input.actor.email,
|
|
actorIp: input.actor.ip,
|
|
action: "plugin.installed",
|
|
targetType: "plugin",
|
|
targetId: row.id,
|
|
sourceModule: "plugin-lifecycle",
|
|
outcome: "success",
|
|
dataSensitivity: "internal",
|
|
metadata: { pluginId: manifest.id, version: manifest.version },
|
|
});
|
|
logger.info({ tenantId: input.tenantId, pluginId: manifest.id, version: manifest.version }, "plugin.installed");
|
|
|
|
return toListItem(row);
|
|
}
|
|
|
|
export async function enablePlugin(args: { tenantId: string; pluginRowId: string; actor: PluginLifecycleActor }): Promise<PluginListItem> {
|
|
assertPluginProductionRuntimeEnabled("Plugin enable");
|
|
return await setEnabled({ ...args, enabled: true });
|
|
}
|
|
|
|
export async function disablePlugin(args: { tenantId: string; pluginRowId: string; actor: PluginLifecycleActor }): Promise<PluginListItem> {
|
|
return await setEnabled({ ...args, enabled: false });
|
|
}
|
|
|
|
/**
|
|
* F-001 — Capability grant types + service.
|
|
*
|
|
* `grantPluginCapabilities` upserts (tenantId, pluginId, capabilityName)
|
|
* rows. When the operator approves all manifest-declared capabilities,
|
|
* `enablePlugin` is then allowed to flip the Plugin row to enabled.
|
|
* Missing grants raise `capability_consent_missing`.
|
|
*
|
|
* Disabling never requires grants — operators must always be able to
|
|
* stop a plugin.
|
|
*/
|
|
export type CapabilityGrant = {
|
|
capabilityName: string;
|
|
scopeJson?: unknown;
|
|
};
|
|
|
|
export type CapabilityRevokeResult = {
|
|
revokedCapabilities: string[];
|
|
autoDisabled: boolean;
|
|
};
|
|
|
|
export type PluginCapabilityGrantRow = {
|
|
id: string;
|
|
tenantId: string;
|
|
pluginId: string;
|
|
capabilityName: string;
|
|
scopeJson: unknown;
|
|
grantedAt: Date;
|
|
grantedByUserId: string | null;
|
|
};
|
|
|
|
export async function listPluginCapabilityGrants(args: { tenantId: string; pluginId: string }): Promise<PluginCapabilityGrantRow[]> {
|
|
return await listPluginCapabilityGrantsWithClient(db, args);
|
|
}
|
|
|
|
async function listPluginCapabilityGrantsWithClient(client: unknown, args: { tenantId: string; pluginId: string }): Promise<PluginCapabilityGrantRow[]> {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const tbl = (client as any).tenantPluginCapabilityGrant;
|
|
if (!tbl) return [];
|
|
const rows = await tbl.findMany({
|
|
where: { tenantId: args.tenantId, pluginId: args.pluginId },
|
|
orderBy: { grantedAt: "desc" },
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
export async function listPluginCapabilityGrantsForPlugins(args: { tenantId: string; pluginIds: ReadonlyArray<string> }): Promise<PluginCapabilityGrantRow[]> {
|
|
const pluginIds = [...new Set(args.pluginIds)].filter((pluginId) => pluginId.length > 0);
|
|
if (pluginIds.length === 0) return [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const tbl = (db as any).tenantPluginCapabilityGrant;
|
|
if (!tbl) return [];
|
|
const rows = await tbl.findMany({
|
|
where: { tenantId: args.tenantId, pluginId: { in: pluginIds } },
|
|
orderBy: { grantedAt: "desc" },
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
export async function grantPluginCapabilities(args: {
|
|
tenantId: string;
|
|
pluginId: string;
|
|
grants: ReadonlyArray<CapabilityGrant>;
|
|
actor: PluginLifecycleActor;
|
|
capabilityReviewDigest?: string;
|
|
}): Promise<PluginCapabilityGrantRow[]> {
|
|
assertPluginProductionRuntimeEnabled("Plugin capability grant");
|
|
// Validate every requested grant is actually declared in the
|
|
// installed plugin's manifest — operators cannot grant capabilities
|
|
// the plugin never asked for.
|
|
const installed = await db.plugin.findUnique({
|
|
where: { tenantId_pluginId: { tenantId: args.tenantId, pluginId: args.pluginId } },
|
|
});
|
|
if (!installed) {
|
|
throw new PluginLifecycleError("not_found", `Plugin ${args.pluginId} not installed for tenant ${args.tenantId}.`);
|
|
}
|
|
const manifest = parseStoredPluginManifest(installed.manifest, { pluginId: installed.pluginId, version: installed.version });
|
|
assertManifestHooksRegistered(manifest);
|
|
assertProductionHooksAvailable(
|
|
"Plugin capability grant",
|
|
args.grants
|
|
.map((grant) => grant.capabilityName)
|
|
.filter(isPluginHookConsentName)
|
|
.map((name) => name.slice("hook:".length))
|
|
);
|
|
const declared = new Map(getPluginConsentRequirements(manifest).map((requirement) => [requirement.name, requirement]));
|
|
assertProductionCapabilitiesAvailable(
|
|
"Plugin capability grant",
|
|
args.grants.map((grant) => grant.capabilityName).filter((name) => !isPluginHookConsentName(name))
|
|
);
|
|
for (const grant of args.grants) {
|
|
const declaredRequirement = declared.get(grant.capabilityName);
|
|
if (!declaredRequirement) {
|
|
throw new PluginLifecycleError("capability_not_in_manifest", `Consent item '${grant.capabilityName}' is not declared in the plugin manifest.`);
|
|
}
|
|
if (
|
|
!isCapabilityGrantScopeSatisfied({
|
|
declaredScope: declaredRequirement.scope ?? null,
|
|
grantedScopeJson: grant.scopeJson ?? null,
|
|
})
|
|
) {
|
|
throw new PluginLifecycleError("capability_scope_invalid", `Consent item '${grant.capabilityName}' grant scope does not match the plugin manifest.`);
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const tbl = (db as any).tenantPluginCapabilityGrant;
|
|
if (!tbl) {
|
|
throw new PluginLifecycleError("not_found", "TenantPluginCapabilityGrant table not migrated; run prisma migrate deploy.");
|
|
}
|
|
const auditGrants = args.grants
|
|
.map((grant) => ({
|
|
capabilityName: grant.capabilityName,
|
|
scopeJson: grant.scopeJson ?? null,
|
|
}))
|
|
.sort((left, right) => left.capabilityName.localeCompare(right.capabilityName));
|
|
const manifestCapabilitySnapshot = auditGrants.map((grant) => {
|
|
const declaredCapability = declared.get(grant.capabilityName);
|
|
return {
|
|
capabilityName: grant.capabilityName,
|
|
declaredScope: declaredCapability?.scope ?? null,
|
|
kind: declaredCapability?.kind ?? "capability",
|
|
};
|
|
});
|
|
|
|
const rows = await db.$transaction(async (tx) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const txTbl = (tx as any).tenantPluginCapabilityGrant;
|
|
const grantedRows: PluginCapabilityGrantRow[] = [];
|
|
for (const grant of args.grants) {
|
|
const row = await txTbl.upsert({
|
|
where: {
|
|
tenantId_pluginId_capabilityName: {
|
|
tenantId: args.tenantId,
|
|
pluginId: args.pluginId,
|
|
capabilityName: grant.capabilityName,
|
|
},
|
|
},
|
|
create: {
|
|
tenantId: args.tenantId,
|
|
pluginId: args.pluginId,
|
|
capabilityName: grant.capabilityName,
|
|
scopeJson: grant.scopeJson as never,
|
|
grantedByUserId: args.actor.userId,
|
|
},
|
|
update: {
|
|
scopeJson: grant.scopeJson as never,
|
|
grantedByUserId: args.actor.userId,
|
|
grantedAt: new Date(),
|
|
},
|
|
});
|
|
grantedRows.push(row);
|
|
}
|
|
await publishAuditEventViaOutbox(tx, {
|
|
tenantId: args.tenantId,
|
|
actorId: args.actor.userId,
|
|
actorType: args.actor.userId ? "user" : "system",
|
|
actorEmail: args.actor.email,
|
|
actorIp: args.actor.ip,
|
|
action: "plugin.capabilities.granted",
|
|
targetType: "plugin",
|
|
targetId: installed.id,
|
|
sourceModule: "plugin-lifecycle",
|
|
outcome: "success",
|
|
dataSensitivity: "internal",
|
|
metadata: {
|
|
pluginId: args.pluginId,
|
|
version: installed.version,
|
|
capabilityReviewDigest: args.capabilityReviewDigest ?? null,
|
|
grantedCapabilities: auditGrants.map((grant) => grant.capabilityName),
|
|
grants: auditGrants,
|
|
manifestCapabilities: manifestCapabilitySnapshot,
|
|
},
|
|
});
|
|
return grantedRows;
|
|
});
|
|
logger.info({ tenantId: args.tenantId, pluginId: args.pluginId, count: args.grants.length }, "plugin.capabilities.granted");
|
|
return rows;
|
|
}
|
|
|
|
export async function revokePluginCapabilities(args: {
|
|
tenantId: string;
|
|
pluginId: string;
|
|
capabilityNames: ReadonlyArray<string>;
|
|
actor: PluginLifecycleActor;
|
|
}): Promise<CapabilityRevokeResult> {
|
|
const capabilityNames = Array.from(new Set(args.capabilityNames.filter((name) => typeof name === "string" && name.length > 0)));
|
|
if (capabilityNames.length === 0) {
|
|
throw new PluginLifecycleError("capability_not_in_manifest", "At least one plugin capability must be selected for revocation.");
|
|
}
|
|
|
|
const installed = await db.plugin.findUnique({
|
|
where: { tenantId_pluginId: { tenantId: args.tenantId, pluginId: args.pluginId } },
|
|
});
|
|
if (!installed) {
|
|
throw new PluginLifecycleError("not_found", `Plugin ${args.pluginId} not installed for tenant ${args.tenantId}.`);
|
|
}
|
|
const manifest = parseStoredPluginManifest(installed.manifest, { pluginId: installed.pluginId, version: installed.version });
|
|
const declared = new Set(getPluginConsentRequirements(manifest).map((requirement) => requirement.name));
|
|
const unknownCapability = capabilityNames.find((capabilityName) => !declared.has(capabilityName));
|
|
if (unknownCapability) {
|
|
throw new PluginLifecycleError("capability_not_in_manifest", `Consent item '${unknownCapability}' is not declared in the plugin manifest.`);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const tbl = (db as any).tenantPluginCapabilityGrant;
|
|
if (!tbl) {
|
|
throw new PluginLifecycleError("not_found", "TenantPluginCapabilityGrant table not migrated; run prisma migrate deploy.");
|
|
}
|
|
const autoDisabled = Boolean(installed.enabled);
|
|
await db.$transaction(async (tx) => {
|
|
if (autoDisabled) {
|
|
await tx.plugin.update({
|
|
where: { id: installed.id },
|
|
data: { enabled: false },
|
|
});
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const txTbl = (tx as any).tenantPluginCapabilityGrant;
|
|
await txTbl.deleteMany({
|
|
where: {
|
|
tenantId: args.tenantId,
|
|
pluginId: args.pluginId,
|
|
capabilityName: { in: capabilityNames },
|
|
},
|
|
});
|
|
await publishAuditEventViaOutbox(tx, {
|
|
tenantId: args.tenantId,
|
|
actorId: args.actor.userId,
|
|
actorType: args.actor.userId ? "user" : "system",
|
|
actorEmail: args.actor.email,
|
|
actorIp: args.actor.ip,
|
|
action: "plugin.capabilities.revoked",
|
|
targetType: "plugin",
|
|
targetId: installed.id,
|
|
sourceModule: "plugin-lifecycle",
|
|
outcome: "success",
|
|
dataSensitivity: "internal",
|
|
metadata: {
|
|
pluginId: args.pluginId,
|
|
revokedCapabilities: capabilityNames,
|
|
autoDisabled,
|
|
},
|
|
});
|
|
});
|
|
logger.info({ tenantId: args.tenantId, pluginId: args.pluginId, count: capabilityNames.length, autoDisabled }, "plugin.capabilities.revoked");
|
|
return { revokedCapabilities: capabilityNames, autoDisabled };
|
|
}
|
|
|
|
async function assertConsentSatisfied(args: { tenantId: string; pluginId: string; manifest: PluginManifest; client?: unknown }): Promise<void> {
|
|
const declared = getPluginConsentRequirements(args.manifest);
|
|
if (declared.length === 0) return; // nothing to consent to
|
|
const grants = await listPluginCapabilityGrantsWithClient(args.client ?? db, {
|
|
tenantId: args.tenantId,
|
|
pluginId: args.pluginId,
|
|
});
|
|
const grantByCapability = new Map(grants.map((g) => [g.capabilityName, g]));
|
|
const missing = declared
|
|
.filter((capability) => {
|
|
const grant = grantByCapability.get(capability.name);
|
|
return (
|
|
!grant ||
|
|
!isCapabilityGrantScopeSatisfied({
|
|
declaredScope: capability.scope ?? null,
|
|
grantedScopeJson: grant.scopeJson,
|
|
})
|
|
);
|
|
})
|
|
.map((requirement) => requirement.name);
|
|
if (missing.length > 0) {
|
|
throw new PluginLifecycleError(
|
|
"capability_consent_missing",
|
|
`Plugin ${args.pluginId} cannot be enabled: missing operator consent for [${missing.join(", ")}].`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function setEnabled(args: { tenantId: string; pluginRowId: string; enabled: boolean; actor: PluginLifecycleActor }): Promise<PluginListItem> {
|
|
assertPluginProductionEscapeHatchesDisabled();
|
|
const row = await db.$transaction(
|
|
async (tx) => {
|
|
const existing = await tx.plugin.findFirst({
|
|
where: { id: args.pluginRowId, tenantId: args.tenantId },
|
|
});
|
|
if (!existing) {
|
|
throw new PluginLifecycleError("not_found", `Plugin row ${args.pluginRowId} not found for tenant ${args.tenantId}.`);
|
|
}
|
|
const manifest = parseStoredPluginManifest(existing.manifest, { pluginId: existing.pluginId, version: existing.version });
|
|
// F-001 — enable requires consent for every manifest-declared
|
|
// capability. Disable is always allowed (kill-switch parity).
|
|
if (args.enabled) {
|
|
assertManifestHooksRegistered(manifest);
|
|
assertProductionHooksAvailable("Plugin enable", manifest.hooks ?? []);
|
|
if (process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT !== "false") {
|
|
assertProductionCapabilitiesAvailable("Plugin enable", manifest.capabilities ?? []);
|
|
await assertConsentSatisfied({
|
|
tenantId: args.tenantId,
|
|
pluginId: existing.pluginId,
|
|
manifest,
|
|
client: tx,
|
|
});
|
|
}
|
|
}
|
|
const updated = await tx.plugin.update({
|
|
where: { id: existing.id },
|
|
data: { enabled: args.enabled },
|
|
});
|
|
await publishAuditEventViaOutbox(tx, {
|
|
tenantId: args.tenantId,
|
|
actorId: args.actor.userId,
|
|
actorType: args.actor.userId ? "user" : "system",
|
|
actorEmail: args.actor.email,
|
|
actorIp: args.actor.ip,
|
|
action: args.enabled ? "plugin.enabled" : "plugin.disabled",
|
|
targetType: "plugin",
|
|
targetId: updated.id,
|
|
sourceModule: "plugin-lifecycle",
|
|
outcome: "success",
|
|
dataSensitivity: "internal",
|
|
metadata: { pluginId: updated.pluginId, version: updated.version },
|
|
});
|
|
return updated;
|
|
},
|
|
{ isolationLevel: "Serializable" }
|
|
);
|
|
logger.info({ tenantId: args.tenantId, pluginId: row.pluginId, enabled: args.enabled }, args.enabled ? "plugin.enabled" : "plugin.disabled");
|
|
return toListItem(row);
|
|
}
|
|
|
|
export async function listPlugins(args: { tenantId: string }): Promise<PluginListItem[]> {
|
|
const rows = await db.plugin.findMany({
|
|
where: { tenantId: args.tenantId },
|
|
orderBy: { installedAt: "desc" },
|
|
});
|
|
return rows.map(toListItem);
|
|
}
|
|
|
|
export async function getPluginForTenant(args: { tenantId: string; pluginRowId: string }): Promise<PluginListItem | null> {
|
|
const row = await db.plugin.findFirst({
|
|
where: { id: args.pluginRowId, tenantId: args.tenantId },
|
|
});
|
|
return row ? toListItem(row) : null;
|
|
}
|
|
|
|
type PluginRow = Awaited<ReturnType<typeof db.plugin.findFirstOrThrow>>;
|
|
|
|
function toListItem(row: PluginRow): PluginListItem {
|
|
const manifest = parseStoredPluginManifest(row.manifest, { pluginId: row.pluginId, version: row.version });
|
|
return {
|
|
id: row.id,
|
|
pluginId: row.pluginId,
|
|
version: row.version,
|
|
enabled: row.enabled,
|
|
manifest,
|
|
installedAt: row.installedAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|