feat: add cross-plugin secret references gated by the provider's RBAC permission

This commit is contained in:
LukeGus committed 2026-09-21 17:09:28 -05:00
1 parent 2d6c1cdecb
commit 9db66d5780
13 files changed
+1782 -10

No files matched your search

+117 -8
View File
@@ -19,6 +19,8 @@ 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 * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { safeDetails } from "./broker.js";
import type { PluginManifest } from "./manifest.js";
@@ -58,6 +60,26 @@ export interface InProcessPluginContext {
options?: { userId?: string },
) => T;
};
/**
* Secrets shared between plugins by reference.
*
* `offer` publishes a resolver, never a value, so the provider keeps the
* ability to rotate or clear it at any time. `getShared` reads another
* plugin's offered secret for one call, gated by that provider's own role
* permission. See secret-registry.ts.
*/
secrets: {
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
};
}
/**
@@ -73,6 +95,8 @@ export interface InProcessHandle {
providedKeys: Array<{ key: string; value: unknown }>;
/** Declared services this plugin provided, revoked on deactivate. */
providedServices: ServiceRegistration[];
/** Shared secrets this plugin offered, withdrawn on deactivate. */
offeredSecrets: SecretRegistration[];
}
export interface PluginModule {
@@ -180,20 +204,61 @@ export function createInProcessContext(
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,
);
},
hasPermission: checkPermission,
audit: (entry) => writeServiceAudit(entry),
}),
},
secrets: {
offer: (key, resolve) => {
// Declared AND offered, the same rule services.provide enforces.
const declared = manifest.providesSecret?.find(
(entry) => entry.key === key,
);
if (!declared) {
throw new Error(
`Plugin ${pluginId} cannot offer secret "${key}": it is not declared in the manifest's providesSecret array`,
);
}
const registration = secretRegistry.offerSecret({
pluginId,
pluginName: manifest.name,
key,
permission: declared.permission,
resolve,
});
handle.offeredSecrets.push(registration);
},
withdraw: (key) => {
const registration = handle.offeredSecrets.find(
(entry) => entry.key === key,
);
handle.offeredSecrets = handle.offeredSecrets.filter(
(entry) => entry !== registration,
);
return secretRegistry.withdrawSecret(pluginId, key, registration);
},
getShared: (providerPluginId, key, options) =>
secretRegistry.readSharedSecret(manifest, providerPluginId, key, {
resolveUserId: () => options?.userId,
hasPermission: checkPermission,
audit: (entry) => writeSecretAudit(entry),
}),
},
};
}
async function checkPermission(
userId: string,
permission: string,
): Promise<boolean> {
const { PermissionManager } = await import("../utils/permission-manager.js");
return PermissionManager.getInstance().hasPermission(userId, permission);
}
/**
* Mirrors PluginBroker.audit: attribution comes from the runtime, never from
* the plugin, and the details only ever record the shape of a call.
@@ -222,6 +287,35 @@ async function writeServiceAudit(
}
}
/**
* Records that a secret was borrowed, never what it was.
*
* `resolved` is the only thing said about the value: whether one came back at
* all. That is what makes the trail useful (you can see a consumer running on
* someone else's key) without the trail itself becoming a place the secret
* leaks to.
*/
async function writeSecretAudit(
entry: secretRegistry.SecretAuditEntry,
): Promise<void> {
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: entry.userId,
username: `plugin:${entry.consumerPluginId}`,
action: "plugin_secret_shared_read",
resourceType: "plugin",
resourceId: entry.providerPluginId,
resourceName: entry.providerPluginName,
details: `${entry.consumerPluginId} read shared secret "${entry.providerPluginId}:${entry.key}" (resolved: ${entry.resolved})`,
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,
@@ -239,6 +333,21 @@ export async function disposeInProcessHandle(
}
handle.providedServices = [];
// Withdrawing the offers is what turns a borrower's getShared into a plain
// null instead of a handle pointing at a resolver whose plugin is gone. The
// sweep afterwards is belt and braces: an offer made outside the ctx, or one
// whose push was lost to a crash mid-activate, would otherwise outlive the
// plugin that backs it.
for (const registration of handle.offeredSecrets) {
secretRegistry.withdrawSecret(
registration.pluginId,
registration.key,
registration,
);
}
handle.offeredSecrets = [];
secretRegistry.withdrawAllForPlugin(pluginId);
for (const unsubscribe of handle.unsubscribers) {
try {
unsubscribe();
+23
View File
@@ -55,6 +55,10 @@ import {
type PluginModule,
} from "./host-ctx.js";
import { resolveRequirements } from "./service-registry.js";
import {
resolveSecretRequirements,
withdrawAllForPlugin,
} from "./secret-registry.js";
export type PluginState =
| "loaded"
@@ -361,11 +365,25 @@ export class PluginLoader {
);
}
// Secret references never block activation, even when not optional. A
// borrowed secret is resolved per call and returns null when absent, so
// a provider installed later starts working with no restart -- refusing
// to start here would turn a recoverable gap into a hard ordering
// dependency between two installs.
const secretResolution = resolveSecretRequirements(plugin.manifest);
for (const reference of secretResolution.unavailable) {
pluginLogger.info(
`Plugin ${plugin.id} shared secret "${reference}" is not currently offered; reads will resolve to null`,
{ operation: "plugin_activate" },
);
}
const handle: InProcessHandle = {
module: { activate, deactivate },
unsubscribers: [],
providedKeys: [],
providedServices: [],
offeredSecrets: [],
};
const ctx = createInProcessContext(plugin.manifest, handle);
@@ -576,6 +594,11 @@ export class PluginLoader {
return;
}
// A worker plugin cannot offer a shared secret today, but terminate() is
// the one path every tier goes through, so the sweep belongs here rather
// than only in the in-process branch above.
withdrawAllForPlugin(plugin.id);
const worker = plugin.worker;
if (!worker) return;
+149
View File
@@ -81,6 +81,12 @@ 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-]+)+$/;
/**
* A shared secret key is a flat slug, e.g. "api-key". Not dotted: the plugin
* id is already a separate field, so a dotted key would only invite encoding
* the owner twice and disagreeing with itself.
*/
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
/**
* Action and slot ids are dotted like service names, but each segment may be
* camelCase: these name a frontend function ("ai.openWithContext"), not a
@@ -145,6 +151,13 @@ export interface PluginManifest {
provides?: PluginServiceProvide[];
/** Named services this plugin needs another active plugin to provide. */
requires?: PluginServiceRequire[];
/**
* Secrets this plugin offers other plugins by reference. Gated by one of
* this plugin's own permissionGroup permissions -- see parseManifest.
*/
providesSecret?: PluginSecretProvide[];
/** Secrets this plugin wants to borrow from another plugin, by reference. */
requiresSecret?: PluginSecretRequire[];
sidecars: Array<{ id: string; binary: string }>;
}
@@ -162,6 +175,28 @@ export interface PluginServiceRequire {
optional?: boolean;
}
export interface PluginSecretProvide {
/** The secret's name within this plugin, e.g. "api-key". */
key: string;
/** The role permission a borrowing user needs. */
permission: string;
/** Shown in the admin UI so the sharing is legible without reading code. */
descriptionKey?: string;
}
export interface PluginSecretRequire {
/** The plugin id that offers the secret. */
plugin: string;
/** The secret's name within that plugin. */
key: string;
/**
* When true, the provider being absent is normal and activation proceeds.
* A non-optional reference still activates -- the read just returns null --
* but resolveSecretRequirements reports it as an error for install feedback.
*/
optional?: boolean;
}
/**
* A named frontend action, declared here and bound at runtime.
*
@@ -300,6 +335,8 @@ export function validateManifest(manifest: unknown): string[] {
errors.push(...validateProvides(m.provides));
errors.push(...validateRequires(m.requires));
errors.push(...validateProvidesSecret(m.providesSecret));
errors.push(...validateRequiresSecret(m.requiresSecret));
if (m.contributes && typeof m.contributes === "object") {
errors.push(
@@ -389,6 +426,92 @@ function validateRequires(requires: unknown): string[] {
return errors;
}
function validateProvidesSecret(providesSecret: unknown): string[] {
if (providesSecret === undefined) return [];
if (!Array.isArray(providesSecret)) {
return ['Field "providesSecret" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
providesSecret.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`providesSecret[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
errors.push(
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
);
} else if (seen.has(e.key)) {
errors.push(`providesSecret[${index}].key is a duplicate: "${e.key}"`);
} else {
seen.add(e.key);
}
if (typeof e.permission !== "string" || e.permission.length === 0) {
errors.push(`providesSecret[${index}].permission is required`);
}
if (
"descriptionKey" in e &&
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
) {
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
}
});
return errors;
}
function validateRequiresSecret(requiresSecret: unknown): string[] {
if (requiresSecret === undefined) return [];
if (!Array.isArray(requiresSecret)) {
return ['Field "requiresSecret" must be an array'];
}
const errors: string[] = [];
const seen = new Set<string>();
requiresSecret.forEach((entry: unknown, index: number) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`requiresSecret[${index}] must be an object`);
return;
}
const e = entry as Record<string, unknown>;
if (typeof e.plugin !== "string" || !ID_PATTERN.test(e.plugin)) {
errors.push(
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${String(e.plugin)}"`,
);
}
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
errors.push(
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
);
}
if (typeof e.plugin === "string" && typeof e.key === "string") {
const id = `${e.plugin}:${e.key}`;
if (seen.has(id)) {
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
} else {
seen.add(id);
}
}
if ("optional" in e && typeof e.optional !== "boolean") {
errors.push(`requiresSecret[${index}].optional must be a boolean`);
}
});
return errors;
}
function validateContributes(contributes: Record<string, unknown>): string[] {
const errors: string[] = [];
@@ -644,6 +767,32 @@ export function parseManifest(raw: unknown): {
}
}
// Same rule for a shared secret. This is what keeps sharing on the existing
// RBAC grant path instead of becoming a separate mechanism: the permission
// gating a borrowed secret is an ordinary role permission in the provider's
// own group, editable per role or per user like any other.
for (const entry of manifest.providesSecret ?? []) {
if (!declaredPermissions.includes(entry.permission)) {
return {
errors: [
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
],
};
}
}
// A plugin borrowing from itself would bypass nothing, but it is always a
// mistake: its own secrets are reachable through ctx.secrets directly.
for (const entry of manifest.requiresSecret ?? []) {
if (entry.plugin === manifest.id) {
return {
errors: [
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
],
};
}
}
// Same rule for UI actions, for the same reason: a permission the catalog
// never sees is one no admin can grant, so the action would be invisible
// forever rather than merely denied.
+362
View File
@@ -0,0 +1,362 @@
/**
* Cross-plugin secret references.
*
* A plugin that holds a secret (the AI assistant and its provider API key) can
* offer it to other plugins by name, without ever handing over a copy. A
* requiring plugin declares `requiresSecret` in its manifest and calls
* ctx.secrets.getShared(pluginId, key) per request; the value is resolved from
* the provider at that moment and returned for that one call.
*
* ## Why a resolver and not a stored value
*
* The registration holds a `resolve` callback owned by the providing plugin,
* never the secret itself. That is the whole point of the design:
*
* - The provider can rotate or clear the underlying secret at any time. The
* next getShared() runs the new resolver and sees the new value, with no
* invalidation step to forget and no cached copy to go stale.
* - There is nothing for the requiring plugin to persist. It gets a string
* back from one call. It could of course write that string somewhere
* itself, exactly as it could with any value it is legitimately given --
* what it cannot do is hold a handle that keeps working after the provider
* revokes, because every call re-resolves through the provider.
* - Uninstalling the provider deletes the registration, so every later
* getShared() returns null rather than throwing on a dangling handle. A
* missing dependency is a normal condition here, unlike a service call,
* which is why this returns null instead of the service registry's
* PluginServiceUnavailableError.
*
* ## Authorization is the provider's own permission
*
* No fourth grant mechanism. The provider declares which of its own role
* permissions gates sharing ("ai.secrets.share", alongside "ai.services.use"),
* and getShared runs the acting user through the same PermissionManager check
* that service-registry.ts and requirePermission() use. So:
*
* - An admin can let one user's requests reuse the shared key and deny
* another's, purely in the role editor, with no reinstall.
* - Revoking the RBAC permission takes effect on the very next call, exactly
* like revoking the secret itself.
* - The check is per-user and per-call, while `requiresSecret` in the
* manifest is per-install. They are independent on purpose: whether the
* plugin may ask is a deploy-time decision, whether this user's request
* gets a value is a runtime one.
*
* A denial is an error (the caller asked for something it is not allowed to
* have), while an absent secret is null (the caller asked for something that
* legitimately is not there). Collapsing the two would let a consumer treat
* "you may not" as "not configured" and fall back to something weaker.
*/
import { pluginLogger } from "../utils/logger.js";
import type { PluginManifest, PluginSecretRequire } from "./manifest.js";
export interface SecretRegistration {
/** The plugin offering the secret. */
pluginId: string;
/** Display name, for audit lines. */
pluginName: string;
/** The secret's name within that plugin, e.g. "api-key". */
key: string;
/** Role permission the acting user needs. Declared by the provider. */
permission: string;
/**
* Returns the current value, or null when the provider has nothing set.
* Called on every read so a rotation is picked up with no invalidation.
*/
resolve: (userId: string) => Promise<string | null> | string | null;
/** Bumped on every offer, so a re-registered provider invalidates nothing stale. */
generation: number;
}
/** Keyed by `${pluginId}:${key}`. */
const secrets = new Map<string, SecretRegistration>();
let generationCounter = 0;
function mapKey(pluginId: string, key: string): string {
return `${pluginId}:${key}`;
}
/**
* Thrown when the acting user lacks the provider's sharing permission.
*
* Mirrors PluginServicePermissionError: `status` and `body` are the 403 shape
* requirePermission() sends, so a consumer serving an HTTP request can pass it
* straight through.
*/
export class PluginSecretPermissionError extends Error {
readonly code = "EPLUGINSECRETPERM";
readonly status = 403;
readonly body: { error: string; required: string };
constructor(pluginId: string, key: string, permission: string) {
super(
`You do not have access to the "${key}" secret shared by "${pluginId}". ` +
`It requires the "${permission}" permission.`,
);
this.name = "PluginSecretPermissionError";
this.body = { error: "Insufficient permissions", required: permission };
}
}
/** Thrown when a read cannot be attributed to a user. */
export class PluginSecretActorError extends Error {
readonly code = "EPLUGINSECRETACTOR";
constructor(pluginId: string, key: string) {
super(
`A read of "${pluginId}:${key}" has no acting user. Pass one with ` +
`ctx.secrets.forUser(userId).getShared(...).`,
);
this.name = "PluginSecretActorError";
}
}
/** Thrown when a plugin asks for a secret its manifest never declared. */
export class PluginSecretUndeclaredError extends Error {
readonly code = "EPLUGINSECRETUNDECLARED";
constructor(consumerPluginId: string, pluginId: string, key: string) {
super(
`Plugin ${consumerPluginId} cannot read "${pluginId}:${key}": it is not ` +
`declared in the manifest's requiresSecret array`,
);
this.name = "PluginSecretUndeclaredError";
}
}
export function offerSecret(
registration: Omit<SecretRegistration, "generation">,
): SecretRegistration {
const id = mapKey(registration.pluginId, registration.key);
const existing = secrets.get(id);
if (existing) {
pluginLogger.warn(
`Shared secret "${id}" is already offered and is being replaced`,
{ operation: "plugin_secret_registry" },
);
}
const entry: SecretRegistration = {
...registration,
generation: ++generationCounter,
};
secrets.set(id, entry);
return entry;
}
/**
* Withdraws an offer, identity-checked like revokeService: a provider that
* crashed and restarted must not withdraw the replacement its own restart
* installed.
*
* Nothing is remembered afterwards. A service keeps a tombstone so a held
* handle throws a named error, but a secret reference has no handle to keep
* alive -- the next read simply finds nothing and returns null.
*/
export function withdrawSecret(
pluginId: string,
key: string,
registration?: SecretRegistration,
): boolean {
const id = mapKey(pluginId, key);
const current = secrets.get(id);
if (!current) return false;
if (registration && current.generation !== registration.generation) {
return false;
}
return secrets.delete(id);
}
/** Withdraws every offer made by one plugin. Used when it is uninstalled. */
export function withdrawAllForPlugin(pluginId: string): number {
let removed = 0;
for (const [id, registration] of secrets) {
if (registration.pluginId === pluginId) {
secrets.delete(id);
removed++;
}
}
return removed;
}
export function getSecretRegistration(
pluginId: string,
key: string,
): SecretRegistration | undefined {
return secrets.get(mapKey(pluginId, key));
}
export function listSecrets(): SecretRegistration[] {
return [...secrets.values()];
}
/** Test seam. */
export function clearSecretRegistry(): void {
secrets.clear();
}
export interface SecretRequirementResolution {
/** Declared requirements whose provider is present and has a value offered. */
available: string[];
/** Declared requirements with no matching offer right now. */
unavailable: string[];
/** Human-readable reasons for each unsatisfied non-optional requirement. */
errors: string[];
}
/**
* Structural resolution of a manifest's `requiresSecret`.
*
* Like resolveRequirements in service-registry.ts, this deliberately does NOT
* consult any user's permissions: activation is per-instance, permissions are
* per-user. It only asks whether the named plugin currently offers the key.
*
* Availability here is a snapshot for logging and install-time feedback. It is
* never cached into the call path -- getShared re-resolves every time, so a
* provider that appears after activation starts working with no restart.
*/
export function resolveSecretRequirements(
manifest: PluginManifest,
): SecretRequirementResolution {
const available: string[] = [];
const unavailable: string[] = [];
const errors: string[] = [];
for (const requirement of manifest.requiresSecret ?? []) {
const id = mapKey(requirement.plugin, requirement.key);
if (secrets.has(id)) {
available.push(id);
continue;
}
unavailable.push(id);
if (!requirement.optional) {
errors.push(
`requires secret "${requirement.key}" from plugin "${requirement.plugin}", which is not currently offered`,
);
}
}
return { available, unavailable, errors };
}
export interface SecretReadContext {
/** Resolves the user this read 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 value returns. */
audit: (entry: SecretAuditEntry) => void | Promise<void>;
}
export interface SecretAuditEntry {
userId: string;
consumerPluginId: string;
providerPluginId: string;
providerPluginName: string;
key: string;
/** False for a denial, true for an allowed read whether or not a value existed. */
success: boolean;
/** Whether a value actually came back. Never the value itself. */
resolved: boolean;
errorMessage?: string;
}
/**
* Reads a shared secret for one call.
*
* Returns null, rather than throwing, when the provider is not installed or
* has nothing set. Those are ordinary conditions a consumer is expected to
* handle (fall back to its own key, or tell the user to configure one), and an
* optional dependency that threw would force every consumer to wrap each read.
*
* Throws only for the two cases that are genuinely wrong: a plugin reading
* something its manifest never declared, and a user without the permission.
*/
export async function readSharedSecret(
consumerManifest: PluginManifest,
providerPluginId: string,
key: string,
context: SecretReadContext,
): Promise<string | null> {
const consumerPluginId = consumerManifest.id;
// Declared in the manifest, the same rule services.provide enforces: a
// plugin cannot reach a secret it never asked for, even if an admin granted
// the permission for some other reason.
const declared = (consumerManifest.requiresSecret ?? []).find(
(entry: PluginSecretRequire) =>
entry.plugin === providerPluginId && entry.key === key,
);
if (!declared) {
throw new PluginSecretUndeclaredError(
consumerPluginId,
providerPluginId,
key,
);
}
const registration = secrets.get(mapKey(providerPluginId, key));
// Provider absent: uninstalled, disabled, or never installed. Indistinguish-
// able on purpose, and all four are the same answer to the consumer -- which
// is also what makes an uninstall clean rather than a dangling handle.
if (!registration) return null;
const userId = context.resolveUserId();
if (!userId) {
throw new PluginSecretActorError(providerPluginId, key);
}
const allowed = await context.hasPermission(userId, registration.permission);
if (!allowed) {
const error = new PluginSecretPermissionError(
providerPluginId,
key,
registration.permission,
);
await context.audit({
userId,
consumerPluginId,
providerPluginId: registration.pluginId,
providerPluginName: registration.pluginName,
key,
success: false,
resolved: false,
errorMessage: error.message,
});
throw error;
}
try {
const value = await registration.resolve(userId);
const resolved = typeof value === "string" && value.length > 0;
await context.audit({
userId,
consumerPluginId,
providerPluginId: registration.pluginId,
providerPluginName: registration.pluginName,
key,
success: true,
resolved,
});
return resolved ? value : null;
} catch (error) {
await context.audit({
userId,
consumerPluginId,
providerPluginId: registration.pluginId,
providerPluginName: registration.pluginName,
key,
success: false,
resolved: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
// A provider whose own lookup failed is not a permission problem, and the
// consumer should not read it as "not configured" and fall back.
throw error;
}
}
+6
View File
@@ -256,6 +256,12 @@ const ctx = {
secrets: {
get: notImplemented("secrets.get"),
set: notImplemented("secrets.set"),
// Cross-plugin sharing is in-process only for now. Named here rather than
// left undefined so a worker plugin that tries it gets the same explicit
// error as any other unimplemented ctx call.
offer: notImplemented("secrets.offer"),
withdraw: notImplemented("secrets.withdraw"),
getShared: notImplemented("secrets.getShared"),
},
notify: notImplemented("notify"),
fetch: notImplemented("fetch"),