mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* feat: 1Password Connect secret sources for SSH credentials Hosts and credentials can hold op://vault/item/field references instead of secrets; they are resolved at connect time from the user's secret source (1Password Connect) at the single point where every subsystem receives plaintext credentials, so terminal, SFTP, Docker, metrics and tunnels all work without per-subsystem changes. Sources are per user, optionally shared, with the access token encrypted under the owner's data key; resolved values are cached briefly in memory. * style: format secret source changes
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../../database/repositories/factory.js", () => ({
|
|
createCurrentSecretSourceRepository: () => ({
|
|
listVisibleToUser: async () => [],
|
|
decryptToken: () => "",
|
|
}),
|
|
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
|
}));
|
|
|
|
const {
|
|
clearExternalSecretCache,
|
|
resolveExternalSecretRefs,
|
|
resolveSecretReference,
|
|
} = await import("../../hosts/external-secrets.js");
|
|
|
|
const source = {
|
|
id: "src-1",
|
|
userId: "alice",
|
|
name: "1P",
|
|
kind: "onepassword-connect",
|
|
baseUrl: "https://connect.internal",
|
|
token: "enc",
|
|
shared: false,
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
};
|
|
|
|
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 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,
|
|
});
|
|
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-----");
|
|
expect(host.username).toBe("op://not-a-secret-field");
|
|
expect(resolver).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("caches a resolved reference per source for a minute", async () => {
|
|
const resolver = vi.fn(async () => "s3cret");
|
|
let clock = 1_000_000;
|
|
const deps = { resolver, pickSource: async () => source, now: () => clock };
|
|
await resolveSecretReference("alice", "op://v/i/f", deps);
|
|
await resolveSecretReference("alice", "op://v/i/f", deps);
|
|
expect(resolver).toHaveBeenCalledTimes(1);
|
|
clock += 61_000;
|
|
await resolveSecretReference("alice", "op://v/i/f", deps);
|
|
expect(resolver).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("fails clearly when the user has no secret source", async () => {
|
|
await expect(
|
|
resolveSecretReference("bob", "op://v/i/f", {
|
|
resolver: async () => "x",
|
|
pickSource: async () => null,
|
|
}),
|
|
).rejects.toThrow(/no secret source/);
|
|
});
|
|
});
|