mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
feat: connect policy hook so plugins can require approved access
This commit is contained in:
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"]);
|
||||
|
||||
Reference in new issue
Block a user