mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: convert secret-sources into a plugin (B20)
This commit is contained in:
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 = () => [];
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user