feat: connect policy hook so plugins can require approved access

This commit is contained in:
LukeGus committed 2026-11-05 22:28:38 -06:00
1 parent 1f5a19f54d
commit 67b70f8a97
20 files changed
+549 -7

No files matched your search

@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const logAudit = vi.fn();
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { warn: vi.fn() },
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: (params: unknown) => logAudit(params),
getAuditUsername: async (userId: string) => `name-${userId}`,
}));
const {
assertConnectAllowed,
clearConnectPolicies,
ConnectPolicyError,
evaluateConnectPolicy,
hasConnectPolicies,
provideConnectPolicy,
} = await import("../../hosts/connect-policy.js");
const input = {
userId: "u1",
hostId: 4,
protocol: "ssh",
purpose: "terminal",
profile: "terminal",
interactive: true,
origin: "ssh-terminal",
};
afterEach(() => {
clearConnectPolicies();
logAudit.mockReset();
});
describe("connect policy", () => {
it("allows with no provider", async () => {
expect(hasConnectPolicies()).toBe(false);
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
expect(await evaluateConnectPolicy(input)).toEqual({ decision: "allow" });
});
it("passes the input to the provider", async () => {
const check = vi.fn(() => ({ decision: "allow" as const }));
provideConnectPolicy("p", { check });
await assertConnectAllowed(input);
expect(check).toHaveBeenCalledWith(input);
});
it("picks the strictest answer", async () => {
provideConnectPolicy("a", {
check: () => ({ decision: "request", reason: "ask", requestTab: "pa" }),
});
provideConnectPolicy("b", { check: () => ({ decision: "allow" }) });
expect(await evaluateConnectPolicy(input)).toEqual({
decision: "request",
reason: "ask",
pluginId: "a",
requestTab: "pa",
});
provideConnectPolicy("c", {
check: async () => ({ decision: "deny", reason: "no" }),
});
const result = await evaluateConnectPolicy(input);
expect(result).toMatchObject({ decision: "deny", pluginId: "c" });
});
it("denies when a provider throws", async () => {
provideConnectPolicy("broken", {
check: () => {
throw new Error("boom");
},
});
expect(await evaluateConnectPolicy(input)).toMatchObject({
decision: "deny",
pluginId: "broken",
});
});
it("throws a typed error and audits the refusal", async () => {
provideConnectPolicy("pa", {
check: () => ({ decision: "request", requestTab: "access" }),
});
const error = await assertConnectAllowed(input).catch((e) => e);
expect(error).toBeInstanceOf(ConnectPolicyError);
expect(error.code).toBe("access-required");
expect(error.status).toBe(403);
expect(error.toJSON()).toEqual({
error: "This host needs approved access",
code: "access-required",
requestTab: "access",
pluginId: "pa",
});
expect(logAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "connect_policy_denied",
userId: "u1",
username: "name-u1",
resourceId: "4",
success: false,
}),
);
});
it("drops a request tab that is not a plain id", async () => {
provideConnectPolicy("pa", {
check: () => ({ decision: "request", requestTab: "../x y" }),
});
expect(await evaluateConnectPolicy(input)).toMatchObject({
requestTab: null,
});
});
it("stops asking once removed", async () => {
const remove = provideConnectPolicy("pa", {
check: () => ({ decision: "deny" }),
});
remove();
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ssh2 from "ssh2";
import { FakeSshClient } from "./pipeline-test-helpers.js";
@@ -83,6 +83,10 @@ import {
} from "../../../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
import type { SshConnectHost } from "../../../hosts/connect/types.js";
import {
clearConnectPolicies,
provideConnectPolicy,
} from "../../../hosts/connect-policy.js";
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
@@ -405,6 +409,46 @@ describe("profile defaults", () => {
});
});
describe("connect policy", () => {
afterEach(() => clearConnectPolicies());
it("refuses before auth is prepared", async () => {
const check = vi.fn(() => ({
decision: "request" as const,
reason: "Ask first",
requestTab: "access",
}));
provideConnectPolicy("pa", { check });
const built = await build(host());
expect(built.provider).toBeNull();
expect(built.outcome).toEqual({
status: "error",
code: "access-required",
message: "Ask first",
requestTab: "access",
});
expect(check).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
hostId: 7,
protocol: "ssh",
profile: "terminal",
origin: "core",
}),
);
});
it("connectHost throws with the policy code", async () => {
provideConnectPolicy("pa", { check: () => ({ decision: "deny" }) });
const error = await connectHost(host(), {
userId: "user-1",
purpose: "test",
}).catch((e) => e);
expect(error).toBeInstanceOf(SshConnectError);
expect(error.code).toBe("access-denied");
});
});
describe("provider registry", () => {
it("registers and removes a plugin provider", async () => {
ensureCoreSshAuthProviders();
@@ -232,6 +232,36 @@ describe("ctx.credentials.resolveHostProtocol", () => {
expect(state.ownerDecrypts).toBe(0);
});
it("asks the connect policy and refuses before decrypting", async () => {
grants.set("demo", ["credentials:read"]);
const { provideConnectPolicy, clearConnectPolicies } =
await import("../../hosts/connect-policy.js");
const check = vi.fn(() => ({
decision: "request" as const,
requestTab: "access",
}));
provideConnectPolicy("pa", { check });
try {
const ctx = spiceContext(["credentials:read"]);
const error = await runAsActor("owner", "request", () =>
ctx.credentials.resolveHostProtocol(7, "spice"),
).catch((e) => e);
expect(error.code).toBe("access-required");
expect(check).toHaveBeenCalledWith(
expect.objectContaining({
userId: "owner",
hostId: 7,
protocol: "spice",
profile: "desktop",
origin: "demo",
}),
);
expect(state.ownerDecrypts).toBe(0);
} finally {
clearConnectPolicies();
}
});
it("returns the owner's stored login and audits it", async () => {
grants.set("demo", ["credentials:read"]);
const ctx = spiceContext(["credentials:read"]);