mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: add cross-plugin secret references gated by the provider's RBAC permission
This commit is contained in:
1 parent
2d6c1cdecb
commit
9db66d5780
13 files changed
+1782
-10
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user