mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 18:45:25 +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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"hosts:status-provide",
|
||||
"hosts:activity-provide",
|
||||
"hosts:lock",
|
||||
"hosts:connect-policy",
|
||||
"desktop:window",
|
||||
"desktop:local",
|
||||
"users:impersonate",
|
||||
|
||||
@@ -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<PluginConnectPolicyDecision>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>();
|
||||
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");
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user