feat: convert secret-sources into a plugin (B20)

This commit is contained in:
LukeGus committed 2026-09-24 16:20:20 -05:00
1 parent 2ac7779eda
commit f4f26a9b8b
72 files changed
+18843 -933

No files matched your search

@@ -0,0 +1,104 @@
/**
* Which plugin resolves a "<scheme>://..." secret reference in a host's
* secret fields ("op://vault/item/field" for 1Password Connect, and so on).
*
* Core registers nothing here: every scheme comes from a plugin through
* ctx.credentials.registerSecretResolver. A reference whose scheme has no
* resolver fails the connect with a message naming the plugin that would
* provide it, the same shape as SshAuthProviderMissingError.
*/
export interface SecretResolverOwner {
pluginId: string;
pluginName: string;
}
export type SecretResolveFn = (
userId: string,
reference: string,
) => Promise<string>;
interface SecretResolverRegistration extends SecretResolverOwner {
resolve: SecretResolveFn;
}
const resolvers = new Map<string, SecretResolverRegistration>();
/**
* Which plugin declares which secret scheme, from every manifest on disk
* including disabled ones. Set by the plugin runtime, mirroring
* setSshAuthTypeOwnerSource.
*/
let ownerSource: () => Array<
SecretResolverOwner & { scheme: string }
> = () => [];
export function setSecretResolverOwnerSource(
source: () => Array<SecretResolverOwner & { scheme: string }>,
): void {
ownerSource = source;
}
function findSchemeOwner(scheme: string): SecretResolverOwner | null {
const owner = ownerSource().find((entry) => entry.scheme === scheme);
return owner
? { pluginId: owner.pluginId, pluginName: owner.pluginName }
: null;
}
export class SecretResolverMissingError extends Error {
readonly code = "SECRET_RESOLVER_MISSING";
constructor(
readonly scheme: string,
readonly owner: SecretResolverOwner | null,
) {
super(
owner
? `This host uses a "${scheme}://" secret reference, which needs the ${owner.pluginName} plugin`
: `This host uses a "${scheme}://" secret reference, which no enabled plugin resolves`,
);
this.name = "SecretResolverMissingError";
}
}
export function registerSecretResolver(
scheme: string,
owner: SecretResolverOwner,
resolve: SecretResolveFn,
): () => void {
const existing = resolvers.get(scheme);
if (existing && existing.pluginId !== owner.pluginId) {
throw new Error(
`Secret scheme "${scheme}" is already resolved by ${existing.pluginId}`,
);
}
const registration = { ...owner, resolve };
resolvers.set(scheme, registration);
return () => {
if (resolvers.get(scheme) === registration) {
resolvers.delete(scheme);
}
};
}
export function getSecretResolver(
scheme: string,
): SecretResolverRegistration | undefined {
return resolvers.get(scheme);
}
export function requireSecretResolver(
scheme: string,
): SecretResolverRegistration {
const resolver = resolvers.get(scheme);
if (!resolver) {
throw new SecretResolverMissingError(scheme, findSchemeOwner(scheme));
}
return resolver;
}
/** Test seam. */
export function resetSecretResolverRegistryForTests(): void {
resolvers.clear();
ownerSource = () => [];
}
+30 -42
View File
@@ -1,15 +1,13 @@
import { createCurrentSecretSourceRepository } from "../database/repositories/factory.js";
import type { SecretSourceRecord } from "../database/repositories/secret-source-repository.js";
import {
isSecretReference,
parseSecretReference,
resolveConnectReference,
} from "../utils/onepassword-connect.js";
import { readSecretSourcePrivateAllowlist } from "../utils/secret-source-egress.js";
getSecretResolver,
requireSecretResolver,
} from "./connect/secret-resolver-registry.js";
/**
* Expands "op://vault/item/field" references in a resolved host's secret
* fields into the actual secrets, fetched from the user's secret source.
* Expands "<scheme>://..." references ("op://vault/item/field" for 1Password
* Connect, and so on) in a resolved host's secret fields into the actual
* secrets, resolved by whichever plugin registered that scheme through
* ctx.credentials.registerSecretResolver.
*
* Runs once per host resolution, at the single point where every subsystem
* gets its plaintext credentials - so terminal, SFTP, Docker, metrics and
@@ -30,6 +28,8 @@ export const SECRET_FIELDS = [
"telnetPassword",
] as const;
const REFERENCE_PATTERN = /^([a-z][a-z0-9+.-]*):\/\//i;
const CACHE_TTL_MS = 60_000;
const cache = new Map<string, { value: string; expiresAt: number }>();
@@ -38,37 +38,27 @@ export function clearExternalSecretCache(): void {
cache.clear();
}
export function isSecretReference(value: unknown): value is string {
return typeof value === "string" && REFERENCE_PATTERN.test(value.trim());
}
function referenceScheme(reference: string): string {
const match = REFERENCE_PATTERN.exec(reference.trim());
return (match?.[1] ?? "").toLowerCase();
}
export type SecretResolver = (
source: SecretSourceRecord,
userId: string,
reference: string,
) => Promise<string>;
async function defaultResolver(
source: SecretSourceRecord,
userId: string,
reference: string,
): Promise<string> {
const ref = parseSecretReference(reference);
if (!ref) throw new Error(`Invalid secret reference: ${reference}`);
const repository = createCurrentSecretSourceRepository();
return resolveConnectReference(
{
baseUrl: source.baseUrl,
token: repository.decryptToken(source),
allowedPrivateHosts: await readSecretSourcePrivateAllowlist(),
},
ref,
);
}
/** The user's own source first, else a shared one. */
export async function pickSecretSource(
userId: string,
): Promise<SecretSourceRecord | null> {
const sources =
await createCurrentSecretSourceRepository().listVisibleToUser(userId);
return (
sources.find((source) => source.userId === userId) ?? sources[0] ?? null
);
const scheme = referenceScheme(reference);
const resolver = requireSecretResolver(scheme);
return resolver.resolve(userId, reference);
}
export async function resolveSecretReference(
@@ -76,22 +66,15 @@ export async function resolveSecretReference(
reference: string,
deps: {
resolver?: SecretResolver;
pickSource?: (userId: string) => Promise<SecretSourceRecord | null>;
now?: () => number;
} = {},
): Promise<string> {
const now = deps.now ?? Date.now;
const source = await (deps.pickSource ?? pickSecretSource)(userId);
if (!source) {
throw new Error(
"This host uses a secret reference but no secret source is configured",
);
}
const cacheKey = `${source.id}:${reference.trim()}`;
const cacheKey = `${userId}:${reference.trim()}`;
const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > now()) return cached.value;
const value = await (deps.resolver ?? defaultResolver)(source, reference);
const value = await (deps.resolver ?? defaultResolver)(userId, reference);
cache.set(cacheKey, { value, expiresAt: now() + CACHE_TTL_MS });
return value;
}
@@ -108,3 +91,8 @@ export async function resolveExternalSecretRefs(
host[field] = await resolveSecretReference(userId, value, deps);
}
}
/** Whether any plugin currently resolves this reference's scheme. */
export function hasSecretResolverFor(reference: string): boolean {
return !!getSecretResolver(referenceScheme(reference));
}