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,165 @@
|
||||
/**
|
||||
* Connect policy from plugins through ctx.hosts.connectPolicy.provide. Core
|
||||
* asks every provider before it builds an SSH config or hands out a remote
|
||||
* desktop login, and the strictest answer wins: deny, then request, then
|
||||
* allow. A provider that throws denies, since letting a session through when
|
||||
* the access rule is broken is worse than a failed connect. No provider means
|
||||
* allow.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginConnectPolicyDecision,
|
||||
PluginConnectPolicyInput,
|
||||
PluginConnectPolicyProvider,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginConnectPolicyProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export type ConnectPolicyResult =
|
||||
| { decision: "allow" }
|
||||
| {
|
||||
decision: "deny" | "request";
|
||||
reason: string;
|
||||
pluginId: string;
|
||||
requestTab: string | null;
|
||||
};
|
||||
|
||||
export class ConnectPolicyError extends Error {
|
||||
readonly status = 403;
|
||||
readonly code: "access-denied" | "access-required";
|
||||
constructor(
|
||||
readonly result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
|
||||
) {
|
||||
super(result.reason);
|
||||
this.name = "ConnectPolicyError";
|
||||
this.code =
|
||||
result.decision === "request" ? "access-required" : "access-denied";
|
||||
}
|
||||
|
||||
/** The body routes send back, so a client can offer the request tab. */
|
||||
toJSON(): {
|
||||
error: string;
|
||||
code: string;
|
||||
requestTab: string | null;
|
||||
pluginId: string;
|
||||
} {
|
||||
return {
|
||||
error: this.message,
|
||||
code: this.code,
|
||||
requestTab: this.result.requestTab,
|
||||
pluginId: this.result.pluginId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function provideConnectPolicy(
|
||||
pluginId: string,
|
||||
provider: PluginConnectPolicyProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearConnectPolicies(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
export function hasConnectPolicies(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
const RANK = { allow: 0, request: 1, deny: 2 } as const;
|
||||
|
||||
function plainTab(value: unknown): string | null {
|
||||
return typeof value === "string" &&
|
||||
/^[a-z0-9][a-z0-9:_.-]{0,80}$/i.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function evaluateConnectPolicy(
|
||||
input: PluginConnectPolicyInput,
|
||||
): Promise<ConnectPolicyResult> {
|
||||
let result: ConnectPolicyResult = { decision: "allow" };
|
||||
for (const entry of providers.values()) {
|
||||
let decision: PluginConnectPolicyDecision;
|
||||
try {
|
||||
decision = (await entry.provider.check(input)) ?? { decision: "allow" };
|
||||
} catch (error) {
|
||||
pluginLogger.warn("Connect policy failed, refusing the connection", {
|
||||
operation: "connect_policy",
|
||||
pluginId: entry.pluginId,
|
||||
hostId: input.hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
decision = {
|
||||
decision: "deny",
|
||||
reason: "Access to this host can't be checked right now",
|
||||
};
|
||||
}
|
||||
const kind =
|
||||
decision.decision === "deny" || decision.decision === "request"
|
||||
? decision.decision
|
||||
: "allow";
|
||||
if (RANK[kind] <= RANK[result.decision]) continue;
|
||||
if (kind === "allow") continue;
|
||||
const reason =
|
||||
"reason" in decision && typeof decision.reason === "string"
|
||||
? decision.reason.slice(0, 300)
|
||||
: "";
|
||||
result = {
|
||||
decision: kind,
|
||||
reason:
|
||||
reason ||
|
||||
(kind === "request"
|
||||
? "This host needs approved access"
|
||||
: "Access to this host is not allowed"),
|
||||
pluginId: entry.pluginId,
|
||||
requestTab:
|
||||
kind === "request" && "requestTab" in decision
|
||||
? plainTab(decision.requestTab)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function auditRefusal(
|
||||
input: PluginConnectPolicyInput,
|
||||
result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
|
||||
): Promise<void> {
|
||||
const audit = await import("../utils/audit-logger.js");
|
||||
const username = await audit
|
||||
.getAuditUsername(input.userId)
|
||||
.catch(() => input.userId);
|
||||
void audit.logAudit({
|
||||
userId: input.userId,
|
||||
username,
|
||||
action: "connect_policy_denied",
|
||||
resourceType: "host",
|
||||
resourceId: String(input.hostId),
|
||||
details: `${input.protocol} ${result.decision} by ${result.pluginId}: ${result.reason}`,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Throws ConnectPolicyError when a provider denies or wants a request. */
|
||||
export async function assertConnectAllowed(
|
||||
input: PluginConnectPolicyInput,
|
||||
): Promise<void> {
|
||||
if (providers.size === 0) return;
|
||||
const result = await evaluateConnectPolicy(input);
|
||||
if (result.decision === "allow") return;
|
||||
await auditRefusal(input, result).catch(() => {});
|
||||
throw new ConnectPolicyError(result);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
requireSshAuthProvider,
|
||||
} from "./auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||
import { ConnectPolicyError, assertConnectAllowed } from "../connect-policy.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthEnv,
|
||||
@@ -113,6 +114,8 @@ export interface BuildConnectConfigOptions {
|
||||
/** Terminal socket for the changed-host-key prompt. */
|
||||
hostKeySocket?: WebSocket | null;
|
||||
interactive?: boolean;
|
||||
/** Plugin id that asked for the connection, or "core". For connect policies. */
|
||||
origin?: string;
|
||||
log?: SshAuthLog;
|
||||
/** Applied after the purpose defaults and before auth is prepared. */
|
||||
overrides?: Partial<MutableConnectConfig>;
|
||||
@@ -197,6 +200,35 @@ export async function buildConnectConfig(
|
||||
log: options.log ?? noopLog,
|
||||
};
|
||||
|
||||
try {
|
||||
await assertConnectAllowed({
|
||||
userId: options.userId,
|
||||
hostId: serverHostId,
|
||||
protocol: "ssh",
|
||||
purpose: String(options.purpose),
|
||||
profile,
|
||||
interactive: options.interactive ?? false,
|
||||
origin: options.origin ?? "core",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConnectPolicyError) {
|
||||
return {
|
||||
config,
|
||||
provider: null,
|
||||
env,
|
||||
outcome: {
|
||||
status: "error",
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.result.requestTab
|
||||
? { requestTab: error.result.requestTab }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let provider: SshAuthProvider;
|
||||
try {
|
||||
provider = requireSshAuthProvider(authType);
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ConnectHostOptions {
|
||||
*/
|
||||
sock?: MutableConnectConfig["sock"];
|
||||
log?: SshAuthLog;
|
||||
/** Plugin id that asked for the connection. For connect policies. */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SshConnection {
|
||||
@@ -107,6 +109,7 @@ export async function connectHost(
|
||||
client,
|
||||
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
||||
interactive: !!options.prompt,
|
||||
origin: options.origin,
|
||||
log: options.log,
|
||||
overrides: options.overrides,
|
||||
});
|
||||
|
||||
@@ -98,8 +98,12 @@ export type SshAuthOutcome =
|
||||
| "invalid-key"
|
||||
| "passphrase-required"
|
||||
| "provider-missing"
|
||||
| "failed";
|
||||
| "failed"
|
||||
| "access-denied"
|
||||
| "access-required";
|
||||
message: string;
|
||||
/** For access-required: a tab of the policy plugin where access is asked for. */
|
||||
requestTab?: string;
|
||||
}
|
||||
| {
|
||||
/** Try once more with these config changes. Used by Tailscale. */
|
||||
|
||||
@@ -354,6 +354,27 @@ export function createPluginCredentials({
|
||||
);
|
||||
}
|
||||
|
||||
// Same connect policy as SSH: a remote desktop login is a session.
|
||||
const { assertConnectAllowed } =
|
||||
await import("../hosts/connect-policy.js");
|
||||
try {
|
||||
await assertConnectAllowed({
|
||||
userId,
|
||||
hostId,
|
||||
protocol: String(protocol),
|
||||
purpose: "desktop-login",
|
||||
profile: "desktop",
|
||||
interactive: true,
|
||||
origin: pluginId,
|
||||
});
|
||||
} catch (error) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
assertManualCreateAllowed,
|
||||
provideHostLocks,
|
||||
} from "../hosts/locks.js";
|
||||
import { provideConnectPolicy } from "../hosts/connect-policy.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -1041,6 +1042,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
},
|
||||
},
|
||||
|
||||
connectPolicy: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:connect-policy")) {
|
||||
throw capabilityRefused(pluginId, "hosts:connect-policy");
|
||||
}
|
||||
const remove = provideConnectPolicy(pluginId, provider);
|
||||
void audit(
|
||||
"host_connect_policy_provide",
|
||||
`${pluginId} decides who can connect`,
|
||||
{ success: true },
|
||||
);
|
||||
const drop = bag?.add(remove, "connect policy provider");
|
||||
return () => {
|
||||
drop?.();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
await requireWrite();
|
||||
|
||||
@@ -263,6 +263,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
userId,
|
||||
purpose: purposeOf(options),
|
||||
profile: profileOf(options),
|
||||
origin: pluginId,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
prompt: options?.prompt,
|
||||
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
||||
@@ -478,6 +479,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
serverHostId: options.serverHostId,
|
||||
log: options.log,
|
||||
interactive: options.interactive,
|
||||
origin: pluginId,
|
||||
hostKeySocket: (options.hostKeySocket ?? null) as never,
|
||||
});
|
||||
await audit("ssh_prepare", describeHost(host), {
|
||||
|
||||
@@ -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