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

@@ -1,46 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSecretSourceRepository: () => ({
listVisibleToUser: async () => [],
decryptToken: () => "",
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
}));
const {
clearExternalSecretCache,
isSecretReference,
resolveExternalSecretRefs,
resolveSecretReference,
} = await import("../../hosts/external-secrets.js");
const { setSecretResolverOwnerSource, resetSecretResolverRegistryForTests } =
await import("../../hosts/connect/secret-resolver-registry.js");
const source = {
id: "src-1",
userId: "alice",
name: "1P",
kind: "onepassword-connect",
baseUrl: "https://connect.internal",
token: "enc",
shared: false,
createdAt: "",
updatedAt: "",
};
describe("isSecretReference", () => {
it("recognises a scheme:// reference without confusing it with a secret", () => {
expect(isSecretReference("op://v/i/f")).toBe(true);
expect(isSecretReference(" op://v/i/f")).toBe(true);
expect(isSecretReference("vault+https://x")).toBe(true);
expect(isSecretReference("hunter2")).toBe(false);
expect(isSecretReference(undefined)).toBe(false);
});
});
describe("external secret references", () => {
beforeEach(() => clearExternalSecretCache());
it("replaces references in the host's secret fields and leaves plain secrets alone", async () => {
const resolver = vi.fn(async (_s, ref: string) => `resolved:${ref}`);
const resolver = vi.fn(
async (_userId: string, ref: string) => `resolved:${ref}`,
);
const host: Record<string, unknown> = {
password: "op://Infra/box/password",
key: "-----BEGIN OPENSSH PRIVATE KEY-----",
sudoPassword: "op://Infra/box/sudo",
username: "op://not-a-secret-field",
};
await resolveExternalSecretRefs(host, "alice", {
resolver,
pickSource: async () => source,
});
await resolveExternalSecretRefs(host, "alice", { resolver });
expect(host.password).toBe("resolved:op://Infra/box/password");
expect(host.sudoPassword).toBe("resolved:op://Infra/box/sudo");
expect(host.key).toBe("-----BEGIN OPENSSH PRIVATE KEY-----");
@@ -48,10 +40,10 @@ describe("external secret references", () => {
expect(resolver).toHaveBeenCalledTimes(2);
});
it("caches a resolved reference per source for a minute", async () => {
it("caches a resolved reference per user for a minute", async () => {
const resolver = vi.fn(async () => "s3cret");
let clock = 1_000_000;
const deps = { resolver, pickSource: async () => source, now: () => clock };
const deps = { resolver, now: () => clock };
await resolveSecretReference("alice", "op://v/i/f", deps);
await resolveSecretReference("alice", "op://v/i/f", deps);
expect(resolver).toHaveBeenCalledTimes(1);
@@ -60,12 +52,26 @@ describe("external secret references", () => {
expect(resolver).toHaveBeenCalledTimes(2);
});
it("fails clearly when the user has no secret source", async () => {
it("fails clearly when no plugin resolves the scheme", async () => {
await expect(
resolveSecretReference("bob", "op://v/i/f", {
resolver: async () => "x",
pickSource: async () => null,
}),
).rejects.toThrow(/no secret source/);
resolveSecretReference("bob", "op://v/i/f", {}),
).rejects.toThrow(/needs the .* plugin|no enabled plugin resolves/);
});
it("names the plugin that would resolve a disabled scheme", async () => {
setSecretResolverOwnerSource(() => [
{
scheme: "op",
pluginId: "secret-sources",
pluginName: "Secret Sources",
},
]);
try {
await expect(
resolveSecretReference("bob", "op://v/i/f", {}),
).rejects.toThrow("needs the Secret Sources plugin");
} finally {
resetSecretResolverRegistryForTests();
}
});
});
@@ -60,9 +60,17 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import {
getSecretResolver,
requireSecretResolver,
resetSecretResolverRegistryForTests,
} from "../../hosts/connect/secret-resolver-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
function contextFor(
capabilities: string[],
contributes?: PluginManifest["contributes"],
) {
const manifest = {
id: "demo",
name: "demo",
@@ -73,6 +81,7 @@ function contextFor(capabilities: string[]) {
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
contributes,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle);
@@ -104,6 +113,7 @@ beforeEach(() => {
state.resolution = null;
state.resolverCalls.length = 0;
state.hosts.set(7, { ...baseHost });
resetSecretResolverRegistryForTests();
});
describe("ctx.credentials.resolveHostProtocol", () => {
@@ -226,3 +236,72 @@ describe("ctx.credentials.resolveHostProtocol", () => {
expect(auditEntries.at(-1)).toMatchObject({ success: false });
});
});
describe("ctx.credentials.registerSecretResolver", () => {
it("refuses without auth:provide", () => {
grants.set("demo", []);
const ctx = contextFor([], { auth: { secretSchemes: ["op"] } });
expect(() =>
ctx.credentials.registerSecretResolver("op", async () => ""),
).toThrow(/auth:provide/);
});
it("refuses a scheme not listed in contributes.auth.secretSchemes", () => {
grants.set("demo", ["auth:provide"]);
const ctx = contextFor(["auth:provide"], { auth: {} });
expect(() =>
ctx.credentials.registerSecretResolver("op", async () => ""),
).toThrow(/secretSchemes/);
});
it("resolves through the registered scheme and audits the call", async () => {
grants.set("demo", ["auth:provide"]);
const ctx = contextFor(["auth:provide"], {
auth: { secretSchemes: ["op"] },
});
ctx.credentials.registerSecretResolver("op", async (userId, ref) => {
state.resolverCalls.push({ userId, ref });
return `resolved:${ref}`;
});
const registration = requireSecretResolver("op");
expect(registration.pluginId).toBe("demo");
const value = await registration.resolve("alice", "op://v/i/f");
expect(value).toBe("resolved:op://v/i/f");
expect(state.resolverCalls[0]).toMatchObject({
userId: "alice",
ref: "op://v/i/f",
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_resolve",
success: true,
});
});
it("is removed when the plugin is deactivated", async () => {
grants.set("demo", ["auth:provide"]);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: ["auth:provide"],
contributes: { auth: { secretSchemes: ["op"] } },
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
const ctx = createPluginContext(manifest, handle);
ctx.credentials.registerSecretResolver("op", async () => "x");
await handle.bag.disposeAll();
expect(getSecretResolver("op")).toBeUndefined();
});
});
@@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isSecretReference,
parseSecretReference,
} from "../../utils/onepassword-connect.js";
describe("1Password secret references", () => {
it("parses op://vault/item/field, ignoring a query suffix", () => {
expect(parseSecretReference("op://Infra/prod-db/password")).toEqual({
vault: "Infra",
item: "prod-db",
field: "password",
});
expect(
parseSecretReference(
"op://Infra/deploy key/private key?ssh-format=openssh",
),
).toEqual({ vault: "Infra", item: "deploy key", field: "private key" });
expect(parseSecretReference("op://Infra/only-two")).toBeNull();
expect(parseSecretReference("https://x")).toBeNull();
});
it("recognises references without confusing them with secrets", () => {
expect(isSecretReference("op://v/i/f")).toBe(true);
expect(isSecretReference(" op://v/i/f")).toBe(true);
expect(isSecretReference("hunter2")).toBe(false);
expect(isSecretReference(undefined)).toBe(false);
});
});
@@ -0,0 +1,206 @@
/**
* The secret source token migration.
*
* An upgrade must be lossless: every source's access token has to reach the
* secret-sources plugin's ctx.secrets, encrypted, and be gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
sources: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "secret-sources" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.token);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "string");
for (const row of rows) if (row.id === id) row.token = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === "secret" &&
entry.scopeId === scopeId &&
entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.sources = new Map([
[
"secret_sources",
[
{ id: "src-1", user_id: "u1", token: "enc:tok-one" },
{ id: "src-2", user_id: "u2", token: "tok-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("runSecretSourcesTokenMigration", () => {
it("moves each source's token into its owner's secrets, encrypted, and clears the column", async () => {
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 2 });
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "source:src-1",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:tok-one"),
});
expect(value("u2", "source:src-2")).toBe("sysenc:tok-plain");
expect(
state.sources.get("secret_sources")!.every((row) => row.token === null),
).toBe(true);
});
it("leaves a token alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 1 });
expect(value("u2", "source:src-2")).toBeUndefined();
const row = state.sources
.get("secret_sources")!
.find((r) => r.id === "src-2");
expect(row?.token).toBe("tok-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.sources = new Map([
[
"p_secret_sources_sources",
[{ id: "src-9", user_id: "u1", token: "enc:tok-late" }],
],
]);
await runSecretSourcesTokenMigration();
expect(value("u1", "source:src-9")).toBe("sysenc:tok-late");
});
it("changes nothing on a second run", async () => {
await runSecretSourcesTokenMigration();
const first = JSON.stringify(state.pluginRows);
const second = await runSecretSourcesTokenMigration();
expect(second).toEqual({ moved: 0 });
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 0 });
expect(state.pluginRows).toEqual([]);
});
});