mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: add cross-plugin secret references gated by the provider's RBAC permission
This commit is contained in:
1 parent
2d6c1cdecb
commit
9db66d5780
13 files changed
+1782
-10
No files matched your search
@@ -150,6 +150,116 @@ describe("plugin manifest validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("providesSecret / requiresSecret", () => {
|
||||
const permissionGroup = {
|
||||
group: "testplugin",
|
||||
permissions: ["testplugin.secrets.share"],
|
||||
};
|
||||
|
||||
it("accepts a well-formed pair", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
validManifest({
|
||||
providesSecret: [
|
||||
{ key: "api-key", permission: "testplugin.secrets.share" },
|
||||
],
|
||||
requiresSecret: [
|
||||
{ plugin: "ai-assistant", key: "api-key", optional: true },
|
||||
],
|
||||
contributes: { permissionGroup },
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a dotted secret key", () => {
|
||||
// The owning plugin is already its own field, so a dotted key would only
|
||||
// encode the owner twice and invite the two disagreeing.
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
providesSecret: [
|
||||
{ key: "ai.api-key", permission: "testplugin.secrets.share" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("providesSecret[0].key"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a duplicate offer of the same key", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
providesSecret: [
|
||||
{ key: "api-key", permission: "testplugin.secrets.share" },
|
||||
{ key: "api-key", permission: "testplugin.secrets.share" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a permission on an offer", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({ providesSecret: [{ key: "api-key" }] }),
|
||||
);
|
||||
expect(
|
||||
errors.some((e) => e.includes("providesSecret[0].permission")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a reference with a bad plugin id", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
requiresSecret: [{ plugin: "Bad_Id", key: "api-key" }],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("requiresSecret[0].plugin"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a duplicate reference to the same plugin and key", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({
|
||||
requiresSecret: [
|
||||
{ plugin: "ai-assistant", key: "api-key" },
|
||||
{ plugin: "ai-assistant", key: "api-key" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a shared secret gated by a permission the group never declares", () => {
|
||||
// Same rule as a service: sharing rides on an ordinary role permission,
|
||||
// so it has to be one an admin can actually grant.
|
||||
const { manifest, errors } = parseManifest(
|
||||
validManifest({
|
||||
providesSecret: [
|
||||
{ key: "api-key", permission: "testplugin.undeclared" },
|
||||
],
|
||||
contributes: { permissionGroup },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors[0]).toContain("testplugin.undeclared");
|
||||
expect(errors[0]).toContain("permissionGroup");
|
||||
});
|
||||
|
||||
it("refuses a plugin borrowing from itself", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
validManifest({
|
||||
requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(manifest).toBeUndefined();
|
||||
expect(errors[0]).toContain("names this plugin itself");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unknown permission", () => {
|
||||
const errors = validateManifest(
|
||||
validManifest({ permissions: ["credentials.read"] }),
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Two real plugins on disk, one offering a secret and one borrowing it, driven
|
||||
* through the actual PluginLoader.
|
||||
*
|
||||
* Same borrowed-id trick as service-contract.test.ts: the in-process allowlist
|
||||
* is hardcoded and deliberately unfakeable, so the fixtures use real
|
||||
* first-party ids to reach that tier. The secret ("api-key") and the
|
||||
* permission gating it ("testplugin.secrets.share") are fixture-owned, so
|
||||
* nothing about the shipped ssh-terminal or docker plugins is under test.
|
||||
*
|
||||
* What the unit test cannot show, and this does: that the manifest declaration
|
||||
* is enforced by the real ctx, and that a genuine uninstall through
|
||||
* loader.deactivate() leaves the borrower with null rather than a handle onto
|
||||
* a resolver whose plugin is gone.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||
granted: new Set<string>(),
|
||||
auditEntries: [] as Record<string, unknown>[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async (params: Record<string, unknown>) => {
|
||||
state.auditEntries.push(params);
|
||||
}),
|
||||
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
state.granted.has(permission),
|
||||
invalidateUserPermissionCache: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||
const { TRANSPORT_OWNER_CAPABILITY } =
|
||||
await import("../../plugins/first-party.js");
|
||||
const {
|
||||
clearSecretRegistry,
|
||||
getSecretRegistration,
|
||||
PluginSecretPermissionError,
|
||||
PluginSecretUndeclaredError,
|
||||
} = await import("../../plugins/secret-registry.js");
|
||||
const { pluginEvents } = await import("../../plugins/events.js");
|
||||
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||
|
||||
const PROVIDER_ID = "ssh-terminal";
|
||||
const CONSUMER_ID = "docker";
|
||||
const KEY = "api-key";
|
||||
const PERMISSION = "testplugin.secrets.share";
|
||||
const SECRET = "sk-fixture-value";
|
||||
|
||||
/**
|
||||
* The provider offers a resolver reading the current value from a file, so the
|
||||
* test can rotate or clear it from outside without restarting the plugin. A
|
||||
* module-level variable would not work: the plugin is imported by URL and
|
||||
* lives in its own ESM module instance.
|
||||
*/
|
||||
function providerSource(valueFile: string): string {
|
||||
const literal = JSON.stringify(valueFile);
|
||||
return `
|
||||
import fs from "node:fs";
|
||||
export async function activate(ctx) {
|
||||
ctx.secrets.offer(${JSON.stringify(KEY)}, async () => {
|
||||
if (!fs.existsSync(${literal})) return null;
|
||||
const value = fs.readFileSync(${literal}, "utf8");
|
||||
return value.length > 0 ? value : null;
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The consumer stashes a bound read on globalThis so the test can invoke it as
|
||||
* a specific user afterwards. Note it stashes a FUNCTION, not a value: there
|
||||
* is no handle to hold, which is the property under test.
|
||||
*/
|
||||
const CONSUMER_SOURCE = `
|
||||
export async function activate(ctx) {
|
||||
globalThis.__borrow = (userId) =>
|
||||
ctx.secrets.getShared(${JSON.stringify(PROVIDER_ID)}, ${JSON.stringify(KEY)}, { userId });
|
||||
globalThis.__borrowUndeclared = (userId) =>
|
||||
ctx.secrets.getShared(${JSON.stringify(PROVIDER_ID)}, "not-declared", { userId });
|
||||
}
|
||||
`;
|
||||
|
||||
function providerFixture(): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: PROVIDER_ID,
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
manifestOverrides: {
|
||||
category: "Terminal",
|
||||
providesSecret: [{ key: KEY, permission: PERMISSION }],
|
||||
contributes: {
|
||||
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function consumerFixture(
|
||||
requiresSecret: Array<Record<string, unknown>> = [
|
||||
{ plugin: PROVIDER_ID, key: KEY, optional: true },
|
||||
],
|
||||
): Fixture {
|
||||
return createFixturePlugin({
|
||||
id: CONSUMER_ID,
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
backendSource: CONSUMER_SOURCE,
|
||||
manifestOverrides: { category: "Infrastructure", requiresSecret },
|
||||
});
|
||||
}
|
||||
|
||||
function borrow(userId = "user-1"): Promise<string | null> {
|
||||
const fn = (globalThis as Record<string, unknown>).__borrow as (
|
||||
id: string,
|
||||
) => Promise<string | null>;
|
||||
return fn(userId);
|
||||
}
|
||||
|
||||
function borrowUndeclared(userId = "user-1"): Promise<string | null> {
|
||||
const fn = (globalThis as Record<string, unknown>).__borrowUndeclared as (
|
||||
id: string,
|
||||
) => Promise<string | null>;
|
||||
return fn(userId);
|
||||
}
|
||||
|
||||
describe("cross-plugin secret references", () => {
|
||||
let provider: Fixture | null = null;
|
||||
let consumer: Fixture | null = null;
|
||||
let loader: InstanceType<typeof PluginLoader> | null = null;
|
||||
let valueFile = "";
|
||||
|
||||
beforeEach(() => {
|
||||
state.granted = new Set([PERMISSION]);
|
||||
state.auditEntries = [];
|
||||
clearSecretRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await loader?.shutdown();
|
||||
loader = null;
|
||||
provider?.cleanup();
|
||||
consumer?.cleanup();
|
||||
provider = null;
|
||||
consumer = null;
|
||||
delete (globalThis as Record<string, unknown>).__borrow;
|
||||
delete (globalThis as Record<string, unknown>).__borrowUndeclared;
|
||||
clearSecretRegistry();
|
||||
pluginEvents.clear();
|
||||
});
|
||||
|
||||
async function activateProvider() {
|
||||
provider = providerFixture();
|
||||
valueFile = path.join(provider.root, "secret.txt");
|
||||
fs.writeFileSync(valueFile, SECRET);
|
||||
fs.writeFileSync(
|
||||
path.join(provider.dir, "backend", "index.mjs"),
|
||||
providerSource(valueFile),
|
||||
);
|
||||
|
||||
loader ??= new PluginLoader();
|
||||
const plugin = await loader.load(provider.dir);
|
||||
await loader.activate(plugin.id);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function activateConsumer(
|
||||
requiresSecret?: Array<Record<string, unknown>>,
|
||||
) {
|
||||
consumer = requiresSecret
|
||||
? consumerFixture(requiresSecret)
|
||||
: consumerFixture();
|
||||
|
||||
loader ??= new PluginLoader();
|
||||
const plugin = await loader.load(consumer.dir);
|
||||
await loader.activate(plugin.id);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function activateBoth() {
|
||||
loader = new PluginLoader();
|
||||
const providerPlugin = await activateProvider();
|
||||
const consumerPlugin = await activateConsumer();
|
||||
return { providerPlugin, consumerPlugin };
|
||||
}
|
||||
|
||||
it("registers the offer when the provider activates", async () => {
|
||||
const { providerPlugin, consumerPlugin } = await activateBoth();
|
||||
|
||||
expect(providerPlugin.state).toBe("active");
|
||||
expect(consumerPlugin.state).toBe("active");
|
||||
|
||||
const registration = getSecretRegistration(PROVIDER_ID, KEY);
|
||||
expect(registration?.pluginId).toBe(PROVIDER_ID);
|
||||
expect(registration?.permission).toBe(PERMISSION);
|
||||
});
|
||||
|
||||
it("resolves for a user who holds the permission", async () => {
|
||||
await activateBoth();
|
||||
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
});
|
||||
|
||||
it("denies a user without the permission, though the secret is set", async () => {
|
||||
await activateBoth();
|
||||
state.granted = new Set();
|
||||
|
||||
await expect(borrow("user-2")).rejects.toBeInstanceOf(
|
||||
PluginSecretPermissionError,
|
||||
);
|
||||
// Still installed, still set: the difference is purely this user's role.
|
||||
expect(getSecretRegistration(PROVIDER_ID, KEY)).toBeDefined();
|
||||
expect(fs.readFileSync(valueFile, "utf8")).toBe(SECRET);
|
||||
});
|
||||
|
||||
it("lets one user borrow while another is denied, with no reinstall", async () => {
|
||||
await activateBoth();
|
||||
|
||||
// The grant is per-user, so both answers come from the same live install.
|
||||
await expect(borrow("allowed-user")).resolves.toBe(SECRET);
|
||||
|
||||
state.granted = new Set();
|
||||
await expect(borrow("denied-user")).rejects.toBeInstanceOf(
|
||||
PluginSecretPermissionError,
|
||||
);
|
||||
|
||||
state.granted = new Set([PERMISSION]);
|
||||
await expect(borrow("allowed-user")).resolves.toBe(SECRET);
|
||||
});
|
||||
|
||||
it("returns null when the provider is installed but the key is not set", async () => {
|
||||
await activateBoth();
|
||||
fs.writeFileSync(valueFile, "");
|
||||
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the providing plugin was never installed", async () => {
|
||||
loader = new PluginLoader();
|
||||
await activateConsumer();
|
||||
|
||||
// The consumer activates regardless: the reference is resolved per call.
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("activates the consumer even when a non-optional reference is absent", async () => {
|
||||
loader = new PluginLoader();
|
||||
const plugin = await activateConsumer([{ plugin: PROVIDER_ID, key: KEY }]);
|
||||
|
||||
// Unlike a service requirement, an absent secret provider is recoverable
|
||||
// by installing it later, so it must not be a hard ordering dependency.
|
||||
expect(plugin.state).toBe("active");
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a read the consumer's manifest never declared", async () => {
|
||||
await activateBoth();
|
||||
|
||||
await expect(borrowUndeclared()).rejects.toBeInstanceOf(
|
||||
PluginSecretUndeclaredError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to offer a secret the provider's manifest never declared", async () => {
|
||||
provider = createFixturePlugin({
|
||||
id: PROVIDER_ID,
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
backendSource: `
|
||||
export async function activate(ctx) {
|
||||
ctx.secrets.offer("undeclared-key", () => "nope");
|
||||
}
|
||||
`,
|
||||
manifestOverrides: {
|
||||
category: "Terminal",
|
||||
providesSecret: [{ key: KEY, permission: PERMISSION }],
|
||||
contributes: {
|
||||
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
loader = new PluginLoader();
|
||||
const plugin = await loader.load(provider.dir);
|
||||
await loader.activate(plugin.id).catch(() => undefined);
|
||||
|
||||
expect(plugin.state).not.toBe("active");
|
||||
expect(
|
||||
getSecretRegistration(PROVIDER_ID, "undeclared-key"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("the provider stays in control", () => {
|
||||
it("serves a rotated value on the next borrow", async () => {
|
||||
await activateBoth();
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
|
||||
fs.writeFileSync(valueFile, "sk-fixture-rotated");
|
||||
|
||||
// Nothing to invalidate: the borrower never held a copy to go stale.
|
||||
await expect(borrow()).resolves.toBe("sk-fixture-rotated");
|
||||
});
|
||||
|
||||
it("nulls the borrow out when the provider clears the secret", async () => {
|
||||
await activateBoth();
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
|
||||
fs.rmSync(valueFile);
|
||||
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstalling the provider", () => {
|
||||
it("nulls out the reference rather than leaving a dangling handle", async () => {
|
||||
const { providerPlugin } = await activateBoth();
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
|
||||
await loader!.deactivate(providerPlugin.id);
|
||||
|
||||
// The borrower is untouched: still active, still permitted, still
|
||||
// declaring the reference. It just gets null now, and does not throw.
|
||||
expect(getSecretRegistration(PROVIDER_ID, KEY)).toBeUndefined();
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps serving nothing even though the underlying value still exists", async () => {
|
||||
const { providerPlugin } = await activateBoth();
|
||||
await loader!.deactivate(providerPlugin.id);
|
||||
|
||||
// The file is still on disk. Access went away with the plugin that
|
||||
// mediated it, which is the point: there was never a copy to fall back on.
|
||||
expect(fs.readFileSync(valueFile, "utf8")).toBe(SECRET);
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resumes when the provider is reinstalled, with no borrower restart", async () => {
|
||||
const { providerPlugin } = await activateBoth();
|
||||
await loader!.deactivate(providerPlugin.id);
|
||||
await expect(borrow()).resolves.toBeNull();
|
||||
|
||||
await loader!.activate(providerPlugin.id);
|
||||
|
||||
await expect(borrow()).resolves.toBe(SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit", () => {
|
||||
it("records a borrow without the value", async () => {
|
||||
await activateBoth();
|
||||
await borrow("user-9");
|
||||
|
||||
const entry = state.auditEntries.at(-1);
|
||||
expect(entry).toMatchObject({
|
||||
userId: "user-9",
|
||||
username: `plugin:${CONSUMER_ID}`,
|
||||
action: "plugin_secret_shared_read",
|
||||
resourceId: PROVIDER_ID,
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.stringify(state.auditEntries)).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("records a denial without the value", async () => {
|
||||
await activateBoth();
|
||||
state.granted = new Set();
|
||||
await borrow().catch(() => undefined);
|
||||
|
||||
expect(state.auditEntries.at(-1)).toMatchObject({ success: false });
|
||||
expect(JSON.stringify(state.auditEntries)).not.toContain(SECRET);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Cross-plugin secret references: the permission gate, the two null cases, and
|
||||
* what an uninstall does to a borrower still pointing at the provider.
|
||||
*
|
||||
* The distinction under test throughout is between a denial (an error, because
|
||||
* the caller asked for something it may not have) and an absence (null, because
|
||||
* the thing legitimately is not there). A consumer that cannot tell those apart
|
||||
* would read "you may not" as "not configured" and fall back to something
|
||||
* weaker, so every case below asserts which of the two it got.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
clearSecretRegistry,
|
||||
getSecretRegistration,
|
||||
listSecrets,
|
||||
offerSecret,
|
||||
readSharedSecret,
|
||||
resolveSecretRequirements,
|
||||
withdrawAllForPlugin,
|
||||
withdrawSecret,
|
||||
PluginSecretActorError,
|
||||
PluginSecretPermissionError,
|
||||
PluginSecretUndeclaredError,
|
||||
} = await import("../../plugins/secret-registry.js");
|
||||
|
||||
const PROVIDER = "ai-assistant";
|
||||
const KEY = "api-key";
|
||||
const PERMISSION = "ai.secrets.share";
|
||||
const SECRET = "sk-live-value";
|
||||
|
||||
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||
let granted = new Set<string>();
|
||||
let auditEntries: Array<Record<string, unknown>> = [];
|
||||
|
||||
function offer(overrides: Partial<Parameters<typeof offerSecret>[0]> = {}) {
|
||||
return offerSecret({
|
||||
pluginId: PROVIDER,
|
||||
pluginName: "AI Assistant",
|
||||
key: KEY,
|
||||
permission: PERMISSION,
|
||||
resolve: () => SECRET,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** A consumer manifest declaring the reference, as the real one would. */
|
||||
function consumer(
|
||||
requiresSecret: Array<{ plugin: string; key: string; optional?: boolean }> = [
|
||||
{ plugin: PROVIDER, key: KEY, optional: true },
|
||||
],
|
||||
) {
|
||||
return { id: "reporting-plugin", requiresSecret } as never;
|
||||
}
|
||||
|
||||
function context(userId: string | undefined) {
|
||||
return {
|
||||
resolveUserId: () => userId,
|
||||
hasPermission: async (_userId: string, permission: string) =>
|
||||
granted.has(permission),
|
||||
audit: (entry: Record<string, unknown>) => {
|
||||
auditEntries.push(entry);
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function read(
|
||||
manifest = consumer(),
|
||||
userId: string | undefined = "user-1",
|
||||
providerPluginId = PROVIDER,
|
||||
key = KEY,
|
||||
) {
|
||||
return readSharedSecret(manifest, providerPluginId, key, context(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* A read with no acting user. Separate from read() on purpose: a default
|
||||
* parameter cannot express this, since passing undefined explicitly would
|
||||
* just fall back to the default and quietly test the wrong thing.
|
||||
*/
|
||||
function readAnonymously(manifest = consumer()) {
|
||||
return readSharedSecret(manifest, PROVIDER, KEY, context(undefined));
|
||||
}
|
||||
|
||||
describe("shared secret registry", () => {
|
||||
beforeEach(() => {
|
||||
clearSecretRegistry();
|
||||
granted = new Set([PERMISSION]);
|
||||
auditEntries = [];
|
||||
});
|
||||
afterEach(() => clearSecretRegistry());
|
||||
|
||||
describe("registration", () => {
|
||||
it("round-trips an offer", () => {
|
||||
const registration = offer();
|
||||
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBe(registration);
|
||||
expect(listSecrets()).toHaveLength(1);
|
||||
expect(registration.generation).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keys offers by plugin as well as name", () => {
|
||||
offer();
|
||||
offer({ pluginId: "other-plugin", resolve: () => "other-value" });
|
||||
|
||||
expect(listSecrets()).toHaveLength(2);
|
||||
expect(getSecretRegistration("other-plugin", KEY)?.pluginId).toBe(
|
||||
"other-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("only withdraws the offer that is actually current", () => {
|
||||
const first = offer();
|
||||
const second = offer();
|
||||
|
||||
// A crashed-and-restarted provider must not withdraw the replacement its
|
||||
// own restart installed.
|
||||
expect(withdrawSecret(PROVIDER, KEY, first)).toBe(false);
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBe(second);
|
||||
|
||||
expect(withdrawSecret(PROVIDER, KEY, second)).toBe(true);
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a withdraw of something never offered", () => {
|
||||
expect(withdrawSecret(PROVIDER, "nothing")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading", () => {
|
||||
it("resolves for a user who holds the permission", async () => {
|
||||
offer();
|
||||
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
});
|
||||
|
||||
it("denies a user without the permission, though the secret exists", async () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
|
||||
// The whole point of the per-call check: the plugin is installed, the
|
||||
// key is set, and this user still gets nothing.
|
||||
await expect(read()).rejects.toBeInstanceOf(PluginSecretPermissionError);
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBeDefined();
|
||||
});
|
||||
|
||||
it("denies with the 403 shape a route can pass straight through", async () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
|
||||
const error = await read().catch((caught) => caught);
|
||||
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.body).toEqual({
|
||||
error: "Insufficient permissions",
|
||||
required: PERMISSION,
|
||||
});
|
||||
});
|
||||
|
||||
it("never runs the provider's resolver for a denied read", async () => {
|
||||
const resolve = vi.fn(() => SECRET);
|
||||
offer({ resolve });
|
||||
granted = new Set();
|
||||
|
||||
await expect(read()).rejects.toBeInstanceOf(PluginSecretPermissionError);
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when the provider is installed but the key is not set", async () => {
|
||||
offer({ resolve: () => null });
|
||||
|
||||
// Absence, not denial: the user may read it, there is just nothing there.
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("treats an empty string as not set", async () => {
|
||||
offer({ resolve: () => "" });
|
||||
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the providing plugin is not installed at all", async () => {
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("gives the same answer for absent and unset, so neither leaks install state", async () => {
|
||||
const unset = await read();
|
||||
offer({ resolve: () => null });
|
||||
const absent = await read();
|
||||
|
||||
expect(unset).toBeNull();
|
||||
expect(absent).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a plugin reading something its manifest never declared", async () => {
|
||||
offer();
|
||||
|
||||
await expect(read(consumer([]), "user-1")).rejects.toBeInstanceOf(
|
||||
PluginSecretUndeclaredError,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a declared reference to a different key on the same plugin", async () => {
|
||||
offer();
|
||||
|
||||
await expect(
|
||||
read(consumer([{ plugin: PROVIDER, key: "other-key" }])),
|
||||
).rejects.toBeInstanceOf(PluginSecretUndeclaredError);
|
||||
});
|
||||
|
||||
it("refuses a read that cannot be attributed to a user", async () => {
|
||||
offer();
|
||||
|
||||
await expect(readAnonymously()).rejects.toBeInstanceOf(
|
||||
PluginSecretActorError,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an anonymous read of an absent provider", async () => {
|
||||
// Absence is settled before the actor is, so a borrower whose provider
|
||||
// is gone gets the same null whether or not it had a user to act as.
|
||||
await expect(readAnonymously()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("passes the acting user to the resolver, so per-user keys stay per-user", async () => {
|
||||
const resolve = vi.fn((userId: string) => `key-for-${userId}`);
|
||||
offer({ resolve });
|
||||
|
||||
await expect(read(consumer(), "user-7")).resolves.toBe("key-for-user-7");
|
||||
expect(resolve).toHaveBeenCalledWith("user-7");
|
||||
});
|
||||
|
||||
it("propagates a resolver failure rather than reporting it as unset", async () => {
|
||||
offer({
|
||||
resolve: () => {
|
||||
throw new Error("vault unreachable");
|
||||
},
|
||||
});
|
||||
|
||||
// Reported as null, a consumer would fall back to a weaker path on what
|
||||
// is actually a transient provider fault.
|
||||
await expect(read()).rejects.toThrow("vault unreachable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("revocation takes effect immediately", () => {
|
||||
it("stops resolving the moment the permission is revoked", async () => {
|
||||
offer();
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
|
||||
granted = new Set();
|
||||
|
||||
// No restart, no reinstall: the next call is already denied.
|
||||
await expect(read()).rejects.toBeInstanceOf(PluginSecretPermissionError);
|
||||
});
|
||||
|
||||
it("serves the rotated value on the next read, with nothing to invalidate", async () => {
|
||||
let current = SECRET;
|
||||
offer({ resolve: () => current });
|
||||
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
current = "sk-live-rotated";
|
||||
await expect(read()).resolves.toBe("sk-live-rotated");
|
||||
});
|
||||
|
||||
it("nulls out a borrower's reference when the provider clears the secret", async () => {
|
||||
let current: string | null = SECRET;
|
||||
offer({ resolve: () => current });
|
||||
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
current = null;
|
||||
await expect(read()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstalling the provider", () => {
|
||||
it("nulls out the reference rather than leaving a dangling handle", async () => {
|
||||
offer();
|
||||
await expect(read()).resolves.toBe(SECRET);
|
||||
|
||||
expect(withdrawAllForPlugin(PROVIDER)).toBe(1);
|
||||
|
||||
// The borrower is still installed, still declares the reference, and
|
||||
// still holds the permission. It simply gets null now, the same answer
|
||||
// as a provider that was never installed -- no throw, no stale value.
|
||||
await expect(read()).resolves.toBeNull();
|
||||
expect(getSecretRegistration(PROVIDER, KEY)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps no copy of the value behind after an uninstall", async () => {
|
||||
const resolve = vi.fn(() => SECRET);
|
||||
offer({ resolve });
|
||||
await read();
|
||||
resolve.mockClear();
|
||||
|
||||
withdrawAllForPlugin(PROVIDER);
|
||||
|
||||
// Nothing cached the earlier read: the answer comes from the absence of
|
||||
// a registration, not from a remembered value.
|
||||
await expect(read()).resolves.toBeNull();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withdraws every key the plugin offered, not just the one read", () => {
|
||||
offer();
|
||||
offer({ key: "webhook-secret" });
|
||||
offer({ pluginId: "unrelated-plugin" });
|
||||
|
||||
expect(withdrawAllForPlugin(PROVIDER)).toBe(2);
|
||||
expect(listSecrets()).toHaveLength(1);
|
||||
expect(getSecretRegistration("unrelated-plugin", KEY)).toBeDefined();
|
||||
});
|
||||
|
||||
it("starts working again if the provider is reinstalled, with no borrower restart", async () => {
|
||||
offer();
|
||||
withdrawAllForPlugin(PROVIDER);
|
||||
await expect(read()).resolves.toBeNull();
|
||||
|
||||
offer({ resolve: () => "sk-live-reinstalled" });
|
||||
|
||||
await expect(read()).resolves.toBe("sk-live-reinstalled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("requirement resolution", () => {
|
||||
it("reports an offered reference as available", () => {
|
||||
offer();
|
||||
|
||||
const resolution = resolveSecretRequirements(consumer());
|
||||
|
||||
expect(resolution.available).toEqual([`${PROVIDER}:${KEY}`]);
|
||||
expect(resolution.unavailable).toEqual([]);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an absent optional reference without an error", () => {
|
||||
const resolution = resolveSecretRequirements(consumer());
|
||||
|
||||
expect(resolution.unavailable).toEqual([`${PROVIDER}:${KEY}`]);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an absent required reference as an error", () => {
|
||||
const resolution = resolveSecretRequirements(
|
||||
consumer([{ plugin: PROVIDER, key: KEY }]),
|
||||
);
|
||||
|
||||
expect(resolution.errors).toHaveLength(1);
|
||||
expect(resolution.errors[0]).toContain(PROVIDER);
|
||||
});
|
||||
|
||||
it("ignores any user's permissions, because activation is per-instance", () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
|
||||
// A user without the grant must get a clear denial from the call, not a
|
||||
// plugin that mysteriously refuses to start.
|
||||
expect(resolveSecretRequirements(consumer()).available).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is empty for a manifest that borrows nothing", () => {
|
||||
const resolution = resolveSecretRequirements({ id: "plain" } as never);
|
||||
|
||||
expect(resolution.available).toEqual([]);
|
||||
expect(resolution.unavailable).toEqual([]);
|
||||
expect(resolution.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("audit", () => {
|
||||
it("records an allowed read without the value", async () => {
|
||||
offer();
|
||||
await read();
|
||||
|
||||
expect(auditEntries).toHaveLength(1);
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
consumerPluginId: "reporting-plugin",
|
||||
providerPluginId: PROVIDER,
|
||||
key: KEY,
|
||||
success: true,
|
||||
resolved: true,
|
||||
});
|
||||
expect(JSON.stringify(auditEntries[0])).not.toContain(SECRET);
|
||||
});
|
||||
|
||||
it("records a denial", async () => {
|
||||
offer();
|
||||
granted = new Set();
|
||||
await read().catch(() => undefined);
|
||||
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
success: false,
|
||||
resolved: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes an allowed read that found nothing", async () => {
|
||||
offer({ resolve: () => null });
|
||||
await read();
|
||||
|
||||
expect(auditEntries[0]).toMatchObject({ success: true, resolved: false });
|
||||
});
|
||||
|
||||
it("writes nothing when the provider is not installed", async () => {
|
||||
await read();
|
||||
|
||||
// There is no provider to attribute the read to, and no decision was
|
||||
// made about the user -- an audit line here would be noise.
|
||||
expect(auditEntries).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user