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
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user