Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+762
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user