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

+58 -2
View File
@@ -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,
+1
View File
@@ -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"),
+1 -1
View File
@@ -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(
+20
View File
@@ -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");