mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user