mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|