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
@@ -9,6 +9,7 @@
|
|||||||
- A click on a host can open its host page, when a plugin offers one
|
- 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
|
- 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
|
- 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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 1.1.0
|
||||||
|
|
||||||
Plugin API 1.15.0.
|
Plugin API 1.15.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# 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
|
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.
|
`@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
|
`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
|
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.
|
`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
|
### 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 |
|
| `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 |
|
| `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: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 |
|
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||||
| `credentials:write` | medium | Save new credentials |
|
| `credentials:write` | medium | Save new credentials |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@
|
|||||||
"hosts:status-provide",
|
"hosts:status-provide",
|
||||||
"hosts:activity-provide",
|
"hosts:activity-provide",
|
||||||
"hosts:lock",
|
"hosts:lock",
|
||||||
|
"hosts:connect-policy",
|
||||||
"desktop:window",
|
"desktop:window",
|
||||||
"desktop:local",
|
"desktop:local",
|
||||||
"users:impersonate",
|
"users:impersonate",
|
||||||
|
|||||||
@@ -1295,6 +1295,54 @@ export interface PluginHostLocksApi {
|
|||||||
provide: (provider: PluginHostLockProvider) => () => void;
|
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
|
* 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.
|
* needs to build a feature (like fleets) on top of hosts it does not own.
|
||||||
@@ -1441,6 +1489,8 @@ export interface PluginHosts {
|
|||||||
activity: PluginHostActivityApi;
|
activity: PluginHostActivityApi;
|
||||||
/** Read only hosts this plugin manages. */
|
/** Read only hosts this plugin manages. */
|
||||||
locks: PluginHostLocksApi;
|
locks: PluginHostLocksApi;
|
||||||
|
/** Who may open sessions to which hosts. */
|
||||||
|
connectPolicy: PluginConnectPolicyApi;
|
||||||
/** Folder rows for the acting user. Needs hosts:write. */
|
/** Folder rows for the acting user. Needs hosts:write. */
|
||||||
folders: PluginHostFoldersApi;
|
folders: PluginHostFoldersApi;
|
||||||
}
|
}
|
||||||
@@ -1668,8 +1718,12 @@ export type PluginSshAuthOutcome =
|
|||||||
| "invalid-key"
|
| "invalid-key"
|
||||||
| "passphrase-required"
|
| "passphrase-required"
|
||||||
| "provider-missing"
|
| "provider-missing"
|
||||||
| "failed";
|
| "failed"
|
||||||
|
| "access-denied"
|
||||||
|
| "access-required";
|
||||||
message: string;
|
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. */
|
/** 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.
|
* 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
|
* 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: (
|
resolveHostProtocol: (
|
||||||
hostId: number,
|
hostId: number,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("hosts:status-provide", "medium"),
|
entry("hosts:status-provide", "medium"),
|
||||||
entry("hosts:activity-provide", "medium"),
|
entry("hosts:activity-provide", "medium"),
|
||||||
entry("hosts:lock", "medium"),
|
entry("hosts:lock", "medium"),
|
||||||
|
entry("hosts:connect-policy", "high"),
|
||||||
entry("users:read", "medium"),
|
entry("users:read", "medium"),
|
||||||
entry("events:core", "medium"),
|
entry("events:core", "medium"),
|
||||||
entry("notify:send", "medium"),
|
entry("notify:send", "medium"),
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* 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. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import type {
|
|||||||
PluginHostStatusEntry,
|
PluginHostStatusEntry,
|
||||||
PluginActivityProvider,
|
PluginActivityProvider,
|
||||||
PluginHostLockProvider,
|
PluginHostLockProvider,
|
||||||
|
PluginConnectPolicyProvider,
|
||||||
PluginAuthPolicyProvider,
|
PluginAuthPolicyProvider,
|
||||||
PluginCredentialSummary,
|
PluginCredentialSummary,
|
||||||
PluginHostShareGrant,
|
PluginHostShareGrant,
|
||||||
@@ -303,6 +304,8 @@ export interface FakePluginContext {
|
|||||||
activityProviders: PluginActivityProvider[];
|
activityProviders: PluginActivityProvider[];
|
||||||
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||||
hostLockProviders: PluginHostLockProvider[];
|
hostLockProviders: PluginHostLockProvider[];
|
||||||
|
/** Every provider passed to ctx.hosts.connectPolicy.provide that is still registered. */
|
||||||
|
connectPolicyProviders: PluginConnectPolicyProvider[];
|
||||||
/** Topics emitted through ctx.events.emit, in order. */
|
/** Topics emitted through ctx.events.emit, in order. */
|
||||||
emitted: Array<{ topic: string; payload: unknown }>;
|
emitted: Array<{ topic: string; payload: unknown }>;
|
||||||
/** Backing store behind ctx.kv. */
|
/** Backing store behind ctx.kv. */
|
||||||
@@ -549,6 +552,7 @@ export function createFakeContext(
|
|||||||
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||||
const activityProviders: PluginActivityProvider[] = [];
|
const activityProviders: PluginActivityProvider[] = [];
|
||||||
const hostLockProviders: PluginHostLockProvider[] = [];
|
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||||
|
const connectPolicyProviders: PluginConnectPolicyProvider[] = [];
|
||||||
const kv = new Map<string, unknown>();
|
const kv = new Map<string, unknown>();
|
||||||
const tables: PluginTableDefinition[] = [];
|
const tables: PluginTableDefinition[] = [];
|
||||||
const syncEntities: SyncEntityRegistration[] = [];
|
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: {
|
status: {
|
||||||
provide: (provider) => {
|
provide: (provider) => {
|
||||||
hostStatusProviders.push(provider);
|
hostStatusProviders.push(provider);
|
||||||
@@ -1586,6 +1599,7 @@ export function createFakeContext(
|
|||||||
hostStatusProviders,
|
hostStatusProviders,
|
||||||
activityProviders,
|
activityProviders,
|
||||||
hostLockProviders,
|
hostLockProviders,
|
||||||
|
connectPolicyProviders,
|
||||||
emitted,
|
emitted,
|
||||||
kv,
|
kv,
|
||||||
tables,
|
tables,
|
||||||
@@ -2080,6 +2094,12 @@ export function createMockCtx(
|
|||||||
return ctx.hosts.locks.provide(provider);
|
return ctx.hosts.locks.provide(provider);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
connectPolicy: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:connect-policy");
|
||||||
|
return ctx.hosts.connectPolicy.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
provide: (provider) => {
|
provide: (provider) => {
|
||||||
require("hosts:read");
|
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,
|
requireSshAuthProvider,
|
||||||
} from "./auth-provider-registry.js";
|
} from "./auth-provider-registry.js";
|
||||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||||
|
import { ConnectPolicyError, assertConnectAllowed } from "../connect-policy.js";
|
||||||
import type {
|
import type {
|
||||||
MutableConnectConfig,
|
MutableConnectConfig,
|
||||||
SshAuthEnv,
|
SshAuthEnv,
|
||||||
@@ -113,6 +114,8 @@ export interface BuildConnectConfigOptions {
|
|||||||
/** Terminal socket for the changed-host-key prompt. */
|
/** Terminal socket for the changed-host-key prompt. */
|
||||||
hostKeySocket?: WebSocket | null;
|
hostKeySocket?: WebSocket | null;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
|
/** Plugin id that asked for the connection, or "core". For connect policies. */
|
||||||
|
origin?: string;
|
||||||
log?: SshAuthLog;
|
log?: SshAuthLog;
|
||||||
/** Applied after the purpose defaults and before auth is prepared. */
|
/** Applied after the purpose defaults and before auth is prepared. */
|
||||||
overrides?: Partial<MutableConnectConfig>;
|
overrides?: Partial<MutableConnectConfig>;
|
||||||
@@ -197,6 +200,35 @@ export async function buildConnectConfig(
|
|||||||
log: options.log ?? noopLog,
|
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;
|
let provider: SshAuthProvider;
|
||||||
try {
|
try {
|
||||||
provider = requireSshAuthProvider(authType);
|
provider = requireSshAuthProvider(authType);
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface ConnectHostOptions {
|
|||||||
*/
|
*/
|
||||||
sock?: MutableConnectConfig["sock"];
|
sock?: MutableConnectConfig["sock"];
|
||||||
log?: SshAuthLog;
|
log?: SshAuthLog;
|
||||||
|
/** Plugin id that asked for the connection. For connect policies. */
|
||||||
|
origin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SshConnection {
|
export interface SshConnection {
|
||||||
@@ -107,6 +109,7 @@ export async function connectHost(
|
|||||||
client,
|
client,
|
||||||
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
||||||
interactive: !!options.prompt,
|
interactive: !!options.prompt,
|
||||||
|
origin: options.origin,
|
||||||
log: options.log,
|
log: options.log,
|
||||||
overrides: options.overrides,
|
overrides: options.overrides,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,8 +98,12 @@ export type SshAuthOutcome =
|
|||||||
| "invalid-key"
|
| "invalid-key"
|
||||||
| "passphrase-required"
|
| "passphrase-required"
|
||||||
| "provider-missing"
|
| "provider-missing"
|
||||||
| "failed";
|
| "failed"
|
||||||
|
| "access-denied"
|
||||||
|
| "access-required";
|
||||||
message: string;
|
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. */
|
/** 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 } =
|
const { createCurrentHostResolutionRepository } =
|
||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
const repository = createCurrentHostResolutionRepository();
|
const repository = createCurrentHostResolutionRepository();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
assertManualCreateAllowed,
|
assertManualCreateAllowed,
|
||||||
provideHostLocks,
|
provideHostLocks,
|
||||||
} from "../hosts/locks.js";
|
} from "../hosts/locks.js";
|
||||||
|
import { provideConnectPolicy } from "../hosts/connect-policy.js";
|
||||||
|
|
||||||
type AuditFn = (
|
type AuditFn = (
|
||||||
action: string,
|
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: {
|
folders: {
|
||||||
ensure: async (name, style) => {
|
ensure: async (name, style) => {
|
||||||
await requireWrite();
|
await requireWrite();
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
userId,
|
userId,
|
||||||
purpose: purposeOf(options),
|
purpose: purposeOf(options),
|
||||||
profile: profileOf(options),
|
profile: profileOf(options),
|
||||||
|
origin: pluginId,
|
||||||
timeoutMs: options?.timeoutMs,
|
timeoutMs: options?.timeoutMs,
|
||||||
prompt: options?.prompt,
|
prompt: options?.prompt,
|
||||||
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
||||||
@@ -478,6 +479,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
|||||||
serverHostId: options.serverHostId,
|
serverHostId: options.serverHostId,
|
||||||
log: options.log,
|
log: options.log,
|
||||||
interactive: options.interactive,
|
interactive: options.interactive,
|
||||||
|
origin: pluginId,
|
||||||
hostKeySocket: (options.hostKeySocket ?? null) as never,
|
hostKeySocket: (options.hostKeySocket ?? null) as never,
|
||||||
});
|
});
|
||||||
await audit("ssh_prepare", describeHost(host), {
|
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 ssh2 from "ssh2";
|
||||||
import { FakeSshClient } from "./pipeline-test-helpers.js";
|
import { FakeSshClient } from "./pipeline-test-helpers.js";
|
||||||
|
|
||||||
@@ -83,6 +83,10 @@ import {
|
|||||||
} from "../../../hosts/connect/auth-provider-registry.js";
|
} from "../../../hosts/connect/auth-provider-registry.js";
|
||||||
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
||||||
import type { SshConnectHost } from "../../../hosts/connect/types.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 plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
|
||||||
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
|
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", () => {
|
describe("provider registry", () => {
|
||||||
it("registers and removes a plugin provider", async () => {
|
it("registers and removes a plugin provider", async () => {
|
||||||
ensureCoreSshAuthProviders();
|
ensureCoreSshAuthProviders();
|
||||||
|
|||||||
@@ -232,6 +232,36 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
|||||||
expect(state.ownerDecrypts).toBe(0);
|
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 () => {
|
it("returns the owner's stored login and audits it", async () => {
|
||||||
grants.set("demo", ["credentials:read"]);
|
grants.set("demo", ["credentials:read"]);
|
||||||
const ctx = spiceContext(["credentials:read"]);
|
const ctx = spiceContext(["credentials:read"]);
|
||||||
|
|||||||
@@ -1251,6 +1251,10 @@
|
|||||||
"title": "Manage hosts as read only",
|
"title": "Manage hosts as read only",
|
||||||
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
|
"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": {
|
"desktop:window": {
|
||||||
"title": "Open desktop windows",
|
"title": "Open desktop windows",
|
||||||
"consequence": "Only in the Termix desktop app."
|
"consequence": "Only in the Termix desktop app."
|
||||||
|
|||||||
Reference in new issue
Block a user