diff --git a/CHANGELOG.md b/CHANGELOG.md index c9cc8ad7c..04c61d65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - A click on a host can open its host page, when a plugin offers one - Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings - A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline +- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens ### Changed diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 29149b129..ce24d81ee 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.2.0 + +Plugin API 1.16.0. + +### Added + +- `ctx.hosts.connectPolicy.provide` and the `hosts:connect-policy` capability, so a plugin can allow, deny or ask for an access request before an SSH or remote desktop session opens +- SSH prepare outcomes can carry the error codes `access-denied` and `access-required` with a `requestTab` + ## 1.1.0 Plugin API 1.15.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index c334d0103..3d06c5659 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.1.0 | Plugin API 1.15.0 +SDK 1.2.0 | Plugin API 1.16.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -354,6 +354,13 @@ router.post("/notes", async (req, res) => { `allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and `ctx.credentials.list()` finds the acting user's credentials by name. +- Connect policy: with `hosts:connect-policy`, `ctx.hosts.connectPolicy.provide({ check(input) })` is asked + before every SSH config is built and every remote desktop login is handed out (`input` has `userId`, + `hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`, + `{ decision: "deny", reason }` or `{ decision: "request", reason, requestTab }`. The strictest answer of + all providers wins and a provider that throws denies. SSH prepare then gives an error outcome with code + `access-denied` or `access-required`, and `ctx.credentials.resolveHostProtocol` throws an error with that + `code` and a `toJSON()` body for a 403. Keep `check` fast, it also runs for background polling. ### Errors @@ -1085,6 +1092,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie | `hosts:activity-provide` | medium | Be the source of the recent activity list | | `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream | | `hosts:lock` | medium | Make hosts it manages read only for everyone else | +| `hosts:connect-policy` | high | Allow, deny or ask for approval before sessions open | | `network:probe` | medium | Check that a host answers on a TCP port | | `credentials:use` | medium | Connect with saved credentials without seeing them | | `credentials:write` | medium | Save new credentials | diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 31698dcab..4469ec1cf 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.1.0", + "version": "1.2.0", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index d4ca15aa0..98560af13 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -171,6 +171,7 @@ "hosts:status-provide", "hosts:activity-provide", "hosts:lock", + "hosts:connect-policy", "desktop:window", "desktop:local", "users:impersonate", diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index d6e685a14..88d16031f 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1295,6 +1295,54 @@ export interface PluginHostLocksApi { provide: (provider: PluginHostLockProvider) => () => void; } +/** What core asks a connect policy before a session to a host opens. */ +export interface PluginConnectPolicyInput { + /** The person connecting. */ + userId: string; + hostId: number; + /** "ssh", or a remote desktop protocol id such as "rdp", "vnc" or "telnet". */ + protocol: string; + /** Free form purpose the caller gave, such as "terminal" or "sftp". */ + purpose: string; + /** + * SSH profile: terminal, session, stream, forward, background or jump. + * "desktop" for remote desktop logins. + */ + profile: string; + /** True when a person waits on this connect (a prompt channel is open). */ + interactive: boolean; + /** Plugin id that asked for the connection, or "core". */ + origin: string; +} + +export type PluginConnectPolicyDecision = + | { decision: "allow" } + | { decision: "deny"; reason?: string } + | { + decision: "request"; + reason?: string; + /** A tab id of the providing plugin where the user can ask for access. */ + requestTab?: string; + }; + +/** + * Decides whether a user may open a session to a host. Core asks every + * provider before it builds an SSH config (terminal, file manager, tunnels, + * background exec) and before it hands out remote desktop logins. The + * strictest answer wins and a provider that throws denies. Keep check fast: + * it runs on every connect, including background polling. + */ +export interface PluginConnectPolicyProvider { + check: ( + input: PluginConnectPolicyInput, + ) => PluginConnectPolicyDecision | Promise; +} + +export interface PluginConnectPolicyApi { + /** Registers this plugin's policy. Needs hosts:connect-policy. Removed on deactivate. */ + provide: (provider: PluginConnectPolicyProvider) => () => void; +} + /** * Hosts the acting user can see, and the RBAC + sharing operations a plugin * needs to build a feature (like fleets) on top of hosts it does not own. @@ -1441,6 +1489,8 @@ export interface PluginHosts { activity: PluginHostActivityApi; /** Read only hosts this plugin manages. */ locks: PluginHostLocksApi; + /** Who may open sessions to which hosts. */ + connectPolicy: PluginConnectPolicyApi; /** Folder rows for the acting user. Needs hosts:write. */ folders: PluginHostFoldersApi; } @@ -1668,8 +1718,12 @@ export type PluginSshAuthOutcome = | "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. */ @@ -2695,7 +2749,9 @@ export interface PluginCredentials { /** * The host's login for one of this plugin's own contributes.protocols. * Null when the host does not exist or the acting user cannot connect to - * it. Needs credentials:read. Audited. + * it. Throws an error with `code` "access-denied" or "access-required" + * (and `requestTab`, `toJSON()` for a 403 body) when a connect policy + * refuses the session. Needs credentials:read. Audited. */ resolveHostProtocol: ( hostId: number, diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 3855fe589..0bb7c1855 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -67,6 +67,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("hosts:status-provide", "medium"), entry("hosts:activity-provide", "medium"), entry("hosts:lock", "medium"), + entry("hosts:connect-policy", "high"), entry("users:read", "medium"), entry("events:core", "medium"), entry("notify:send", "medium"), diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 281fdb6c5..d503e1cce 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -42,7 +42,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.15.0"; +export const PLUGIN_API_VERSION = "1.16.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index d56ca10e5..27726acb8 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -81,6 +81,7 @@ import type { PluginHostStatusEntry, PluginActivityProvider, PluginHostLockProvider, + PluginConnectPolicyProvider, PluginAuthPolicyProvider, PluginCredentialSummary, PluginHostShareGrant, @@ -303,6 +304,8 @@ export interface FakePluginContext { activityProviders: PluginActivityProvider[]; /** Every provider passed to ctx.hosts.locks.provide that is still registered. */ hostLockProviders: PluginHostLockProvider[]; + /** Every provider passed to ctx.hosts.connectPolicy.provide that is still registered. */ + connectPolicyProviders: PluginConnectPolicyProvider[]; /** Topics emitted through ctx.events.emit, in order. */ emitted: Array<{ topic: string; payload: unknown }>; /** Backing store behind ctx.kv. */ @@ -549,6 +552,7 @@ export function createFakeContext( const hostStatusProviders: PluginHostStatusProvider[] = []; const activityProviders: PluginActivityProvider[] = []; const hostLockProviders: PluginHostLockProvider[] = []; + const connectPolicyProviders: PluginConnectPolicyProvider[] = []; const kv = new Map(); const tables: PluginTableDefinition[] = []; const syncEntities: SyncEntityRegistration[] = []; @@ -1093,6 +1097,15 @@ export function createFakeContext( }; }, }, + connectPolicy: { + provide: (provider) => { + connectPolicyProviders.push(provider); + return () => { + const at = connectPolicyProviders.indexOf(provider); + if (at >= 0) connectPolicyProviders.splice(at, 1); + }; + }, + }, status: { provide: (provider) => { hostStatusProviders.push(provider); @@ -1586,6 +1599,7 @@ export function createFakeContext( hostStatusProviders, activityProviders, hostLockProviders, + connectPolicyProviders, emitted, kv, tables, @@ -2080,6 +2094,12 @@ export function createMockCtx( return ctx.hosts.locks.provide(provider); }, }, + connectPolicy: { + provide: (provider) => { + require("hosts:connect-policy"); + return ctx.hosts.connectPolicy.provide(provider); + }, + }, status: { provide: (provider) => { require("hosts:read"); diff --git a/src/backend/hosts/connect-policy.ts b/src/backend/hosts/connect-policy.ts new file mode 100644 index 000000000..9ebb469d6 --- /dev/null +++ b/src/backend/hosts/connect-policy.ts @@ -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(); + +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, + ) { + 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 { + 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, +): Promise { + 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 { + if (providers.size === 0) return; + const result = await evaluateConnectPolicy(input); + if (result.decision === "allow") return; + await auditRefusal(input, result).catch(() => {}); + throw new ConnectPolicyError(result); +} diff --git a/src/backend/hosts/connect/build-connect-config.ts b/src/backend/hosts/connect/build-connect-config.ts index 4d0e78bb5..78a2b9cbe 100644 --- a/src/backend/hosts/connect/build-connect-config.ts +++ b/src/backend/hosts/connect/build-connect-config.ts @@ -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; @@ -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); diff --git a/src/backend/hosts/connect/connect-host.ts b/src/backend/hosts/connect/connect-host.ts index 8f5b3c2d2..18c193d43 100644 --- a/src/backend/hosts/connect/connect-host.ts +++ b/src/backend/hosts/connect/connect-host.ts @@ -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, }); diff --git a/src/backend/hosts/connect/types.ts b/src/backend/hosts/connect/types.ts index 1925905fb..f7282b936 100644 --- a/src/backend/hosts/connect/types.ts +++ b/src/backend/hosts/connect/types.ts @@ -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. */ diff --git a/src/backend/plugins/ctx-credentials.ts b/src/backend/plugins/ctx-credentials.ts index ed2fdd7a7..0f812c213 100644 --- a/src/backend/plugins/ctx-credentials.ts +++ b/src/backend/plugins/ctx-credentials.ts @@ -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(); diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index 6cd1095fb..4e2bad4b2 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -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(); diff --git a/src/backend/plugins/ctx-ssh-auth.ts b/src/backend/plugins/ctx-ssh-auth.ts index db9d3a1e9..45657dad1 100644 --- a/src/backend/plugins/ctx-ssh-auth.ts +++ b/src/backend/plugins/ctx-ssh-auth.ts @@ -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, @@ -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), { diff --git a/src/backend/tests/hosts/connect-policy.test.ts b/src/backend/tests/hosts/connect-policy.test.ts new file mode 100644 index 000000000..8e2c6901c --- /dev/null +++ b/src/backend/tests/hosts/connect-policy.test.ts @@ -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(); + }); +}); diff --git a/src/backend/tests/hosts/connect/pipeline.test.ts b/src/backend/tests/hosts/connect/pipeline.test.ts index c1000d4e0..9e90cd167 100644 --- a/src/backend/tests/hosts/connect/pipeline.test.ts +++ b/src/backend/tests/hosts/connect/pipeline.test.ts @@ -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(); diff --git a/src/backend/tests/plugins/ctx-credentials.test.ts b/src/backend/tests/plugins/ctx-credentials.test.ts index 2f07a00b3..2a38c75bf 100644 --- a/src/backend/tests/plugins/ctx-credentials.test.ts +++ b/src/backend/tests/plugins/ctx-credentials.test.ts @@ -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"]); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index ae3a744cc..5207d7249 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1251,6 +1251,10 @@ "title": "Manage hosts as read only", "consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else." }, + "hosts:connect-policy": { + "title": "Decide who can connect", + "consequence": "Can block or allow every SSH and remote desktop connection to any host." + }, "desktop:window": { "title": "Open desktop windows", "consequence": "Only in the Termix desktop app."