mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: host lock api so a plugin can load hosts from a config file (#1581)
This commit is contained in:
1 parent
1cdbfc674c
commit
2c785fe35b
34 files changed
+1388
-52
No files matched your search
@@ -14,6 +14,8 @@
|
||||
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
|
||||
- Browse shows plugins a page at a time
|
||||
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
|
||||
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
|
||||
- A plugin can block adding hosts by hand, so every host comes from one place
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.14
|
||||
|
||||
Plugin API 1.6.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
|
||||
- `ctx.hosts.setShares` to replace every grant on a host in one call
|
||||
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
|
||||
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
|
||||
|
||||
## 1.0.13
|
||||
|
||||
Plugin API 1.5.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.13 | Plugin API 1.5.0
|
||||
SDK 1.0.14 | Plugin API 1.6.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.
|
||||
@@ -300,6 +300,11 @@ router.post("/notes", async (req, res) => {
|
||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
|
||||
`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.
|
||||
|
||||
### Errors
|
||||
|
||||
@@ -898,6 +903,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||
| `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.0.13",
|
||||
"version": "1.0.14",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"network:probe",
|
||||
"hosts:status-provide",
|
||||
"hosts:activity-provide",
|
||||
"hosts:lock",
|
||||
"desktop:window",
|
||||
"desktop:local",
|
||||
"users:impersonate",
|
||||
|
||||
@@ -698,6 +698,36 @@ export interface PluginHostCreateInput {
|
||||
sshOptions?: HostSshOptions | null;
|
||||
/** Host settings by plugin id, the shape a Termix export carries. */
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Each plugin's own host switch (its manifest enableKey), by plugin id.
|
||||
* A plugin that is not running is skipped and logged; one with no switch
|
||||
* is refused.
|
||||
*/
|
||||
pluginEnable?: Record<string, boolean>;
|
||||
/**
|
||||
* Secrets, stored encrypted with the owner's data key and never returned
|
||||
* in a PluginHostRecord. An empty string or null clears the stored value.
|
||||
*/
|
||||
password?: string | null;
|
||||
key?: string | null;
|
||||
keyPassword?: string | null;
|
||||
keyType?: string | null;
|
||||
sudoPassword?: string | null;
|
||||
/**
|
||||
* Logins for plugin protocols (contributes.protocols), keyed by protocol
|
||||
* id. null removes that protocol's login. Unknown protocol ids are skipped.
|
||||
*/
|
||||
protocolAuth?: Record<string, PluginHostProtocolAuthInput | null>;
|
||||
}
|
||||
|
||||
/** One protocol login on a host write. Absent keys keep what is stored. */
|
||||
export interface PluginHostProtocolAuthInput {
|
||||
authType?: "direct" | "credential" | "none";
|
||||
credentialId?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
/** The protocol's declared credentialFields, secret ones included. */
|
||||
fields?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Fields a plugin may change on a host the acting user owns. */
|
||||
@@ -725,6 +755,13 @@ export const PLUGIN_HOST_INPUT_KEYS = [
|
||||
"forceKeyboardInteractive",
|
||||
"sshOptions",
|
||||
"pluginSettings",
|
||||
"pluginEnable",
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"keyType",
|
||||
"sudoPassword",
|
||||
"protocolAuth",
|
||||
] as const satisfies readonly (keyof PluginHostCreateInput)[];
|
||||
|
||||
/** The same shape canAccessHost returns internally, without secrets. */
|
||||
@@ -761,6 +798,34 @@ export interface PluginShareableRole {
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/** One grant in ctx.hosts.setShares. */
|
||||
export interface PluginHostShareGrant {
|
||||
target: PluginShareTarget;
|
||||
permissionLevel: PluginHostShareLevel;
|
||||
/** Omit for a grant that never expires. */
|
||||
durationHours?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts a plugin manages itself, for example from a config file. Core refuses
|
||||
* edits, deletes and share changes on a locked host from the UI, the API and
|
||||
* other plugins, and marks it in host reads so a layout can show it read only.
|
||||
* Pins and order stay with the user. Only the plugin that locks a host may
|
||||
* still write it.
|
||||
*/
|
||||
export interface PluginHostLockProvider {
|
||||
isLocked: (hostId: number) => boolean;
|
||||
/** A short line shown under the managed note, or null. */
|
||||
note: (hostId: number) => string | null;
|
||||
/** False blocks adding hosts by hand (editor, import, other plugins). */
|
||||
allowManualCreate: () => boolean;
|
||||
}
|
||||
|
||||
export interface PluginHostLocksApi {
|
||||
/** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */
|
||||
provide: (provider: PluginHostLockProvider) => () => 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.
|
||||
@@ -823,6 +888,15 @@ export interface PluginHosts {
|
||||
permissionLevel: PluginHostShareLevel,
|
||||
durationHours?: number,
|
||||
) => Promise<PluginHostShareResult>;
|
||||
/**
|
||||
* Replaces every grant on a host the acting user owns with this list.
|
||||
* Grants that stay keep their row and counters, missing ones are revoked.
|
||||
* Needs hosts:write.
|
||||
*/
|
||||
setShares: (
|
||||
hostId: number,
|
||||
grants: PluginHostShareGrant[],
|
||||
) => Promise<PluginHostShareResult>;
|
||||
/** Users the caller may pick as a share target, for building a picker UI. */
|
||||
listUsers: () => Promise<PluginShareableUser[]>;
|
||||
/** Non-system roles the caller may pick as a share target. */
|
||||
@@ -896,6 +970,8 @@ export interface PluginHosts {
|
||||
) => () => void;
|
||||
/** The recent activity list on the dashboard. */
|
||||
activity: PluginHostActivityApi;
|
||||
/** Read only hosts this plugin manages. */
|
||||
locks: PluginHostLocksApi;
|
||||
}
|
||||
|
||||
export interface PluginHostImportOptions {
|
||||
@@ -2011,6 +2087,15 @@ export interface PluginSshKeyCredential {
|
||||
publicKey: string | null;
|
||||
}
|
||||
|
||||
/** A saved credential without its secrets. */
|
||||
export interface PluginCredentialSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string | null;
|
||||
authType: string;
|
||||
folder: string | null;
|
||||
}
|
||||
|
||||
/** A key pair to save as a new credential for the acting user. */
|
||||
export interface PluginSshKeyCredentialInput {
|
||||
name: string;
|
||||
@@ -2032,6 +2117,12 @@ export interface PluginCredentials {
|
||||
* Needs credentials:use and the core credentials.view permission. Audited.
|
||||
*/
|
||||
listSshKeys: () => Promise<PluginSshKeyCredential[]>;
|
||||
/**
|
||||
* The acting user's own credentials by name, with no secret material, so a
|
||||
* plugin can point a host at one. Needs credentials:use and the core
|
||||
* credentials.view permission. Audited.
|
||||
*/
|
||||
list: () => Promise<PluginCredentialSummary[]>;
|
||||
/**
|
||||
* Saves a key pair as a new encrypted credential owned by the acting user
|
||||
* and returns its id. Needs credentials:write, the core credentials.create
|
||||
|
||||
@@ -61,6 +61,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("network:probe", "medium"),
|
||||
entry("hosts:status-provide", "medium"),
|
||||
entry("hosts:activity-provide", "medium"),
|
||||
entry("hosts:lock", "medium"),
|
||||
entry("users:read", "medium"),
|
||||
entry("events:core", "medium"),
|
||||
entry("notify:send", "medium"),
|
||||
|
||||
@@ -37,7 +37,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.5.0";
|
||||
export const PLUGIN_API_VERSION = "1.6.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -76,6 +76,9 @@ import type {
|
||||
PluginHostProbeTarget,
|
||||
PluginHostStatusEntry,
|
||||
PluginActivityProvider,
|
||||
PluginHostLockProvider,
|
||||
PluginCredentialSummary,
|
||||
PluginHostShareGrant,
|
||||
PluginHostStatusProvider,
|
||||
PluginNotification,
|
||||
PluginNotificationChannel,
|
||||
@@ -140,6 +143,8 @@ export interface FakeContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys answers. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.credentials.list answers. */
|
||||
credentialSummaries?: PluginCredentialSummary[];
|
||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
||||
@@ -285,6 +290,8 @@ export interface FakePluginContext {
|
||||
hostStatusProviders: PluginHostStatusProvider[];
|
||||
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
||||
activityProviders: PluginActivityProvider[];
|
||||
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||
hostLockProviders: PluginHostLockProvider[];
|
||||
/** Topics emitted through ctx.events.emit, in order. */
|
||||
emitted: Array<{ topic: string; payload: unknown }>;
|
||||
/** Backing store behind ctx.kv. */
|
||||
@@ -317,6 +324,8 @@ export interface FakePluginContext {
|
||||
permissionLevel: string;
|
||||
durationHours?: number;
|
||||
}>;
|
||||
/** Every ctx.hosts.setShares call, in order. */
|
||||
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
|
||||
/** Everything registered through ctx.auth. */
|
||||
auth: FakeAuthRegistrations;
|
||||
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
||||
@@ -488,7 +497,14 @@ function applyHostInput(
|
||||
const {
|
||||
tags,
|
||||
pluginSettings: _settings,
|
||||
pluginEnable: _pluginEnable,
|
||||
forceKeyboardInteractive: _fki,
|
||||
password: _password,
|
||||
key: _key,
|
||||
keyPassword: _keyPassword,
|
||||
keyType: _keyType,
|
||||
sudoPassword: _sudoPassword,
|
||||
protocolAuth: _protocolAuth,
|
||||
...rest
|
||||
} = input;
|
||||
const next: PluginHostRecord = { ...record };
|
||||
@@ -517,6 +533,7 @@ export function createFakeContext(
|
||||
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
||||
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||
const activityProviders: PluginActivityProvider[] = [];
|
||||
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
@@ -534,6 +551,7 @@ export function createFakeContext(
|
||||
}>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const hostShareSets: FakePluginContext["hostShareSets"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
||||
[];
|
||||
@@ -1004,6 +1022,10 @@ export function createFakeContext(
|
||||
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
setShares: async (hostId, grants) => {
|
||||
hostShareSets.push({ hostId, grants: [...grants] });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
listUsers: async () => options.shareableUsers ?? [],
|
||||
listRoles: async () => options.shareableRoles ?? [],
|
||||
trackSession: (hostId) => {
|
||||
@@ -1034,6 +1056,15 @@ export function createFakeContext(
|
||||
};
|
||||
},
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
hostLockProviders.push(provider);
|
||||
return () => {
|
||||
const at = hostLockProviders.indexOf(provider);
|
||||
if (at >= 0) hostLockProviders.splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
hostStatusProviders.push(provider);
|
||||
@@ -1286,6 +1317,7 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
credentials: {
|
||||
list: async () => [...(options.credentialSummaries ?? [])],
|
||||
listSshKeys: async () => [
|
||||
...(options.sshKeyCredentials ?? []),
|
||||
...createdSshKeys.map((key) => ({
|
||||
@@ -1468,6 +1500,7 @@ export function createFakeContext(
|
||||
disposals,
|
||||
hostStatusProviders,
|
||||
activityProviders,
|
||||
hostLockProviders,
|
||||
emitted,
|
||||
kv,
|
||||
tables,
|
||||
@@ -1478,6 +1511,7 @@ export function createFakeContext(
|
||||
coreSettings,
|
||||
sshConnections,
|
||||
hostShares,
|
||||
hostShareSets,
|
||||
auth,
|
||||
desktopWindows,
|
||||
externalClientLaunches,
|
||||
@@ -1560,6 +1594,8 @@ export interface MockContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys serves. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.credentials.list serves. */
|
||||
credentialSummaries?: PluginCredentialSummary[];
|
||||
/** What ctx.desktop.available() answers. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.desktop.local is. See FakeContextOptions. */
|
||||
@@ -1699,6 +1735,7 @@ export function createMockCtx(
|
||||
services: options.services,
|
||||
protocolTargets: options.protocolTargets,
|
||||
sshKeyCredentials: options.sshKeyCredentials,
|
||||
credentialSummaries: options.credentialSummaries,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
desktopLocal: options.desktopLocal,
|
||||
auditEntries: options.auditEntries,
|
||||
@@ -1900,6 +1937,10 @@ export function createMockCtx(
|
||||
require("hosts:write");
|
||||
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||
},
|
||||
setShares: async (hostId, grants) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.setShares(hostId, grants);
|
||||
},
|
||||
listUsers: async () => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.listUsers();
|
||||
@@ -1930,6 +1971,12 @@ export function createMockCtx(
|
||||
return ctx.hosts.activity.provide(provider);
|
||||
},
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
require("hosts:lock");
|
||||
return ctx.hosts.locks.provide(provider);
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
require("hosts:read");
|
||||
@@ -2098,6 +2145,10 @@ export function createMockCtx(
|
||||
},
|
||||
|
||||
credentials: {
|
||||
list: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.list();
|
||||
},
|
||||
listSshKeys: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.listSshKeys();
|
||||
|
||||
@@ -151,7 +151,9 @@ HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
HostGroupKey (type): HostGroupKey
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
HostLock (type): HostLock
|
||||
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||
HostPolicy (type): HostPolicy
|
||||
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
@@ -411,6 +413,7 @@ getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId
|
||||
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
getHostPolicy (value): () => Promise<HostPolicy>
|
||||
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||
getHostTags (value): () => Promise<string[]>
|
||||
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||
|
||||
@@ -168,6 +168,8 @@ const ALLOWED = [
|
||||
"HostDefaultsTarget",
|
||||
"HostDensity",
|
||||
"HostFeatureFields",
|
||||
"HostLock",
|
||||
"HostPolicy",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostFolder",
|
||||
"HostGroupKey",
|
||||
@@ -432,6 +434,7 @@ const ALLOWED = [
|
||||
"getHostDefaultsLevel",
|
||||
"getHostEditorSection",
|
||||
"getHostPassword",
|
||||
"getHostPolicy",
|
||||
"getHostSidebarPreferences",
|
||||
"getHostTags",
|
||||
"getLiveHostStatus",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { sshLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||
|
||||
import { setHostPluginEnabled } from "./host-plugin-settings.js";
|
||||
|
||||
@@ -141,6 +142,15 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
|
||||
const ownedIds = ownedHosts.map((h) => h.id);
|
||||
// Pins stay the user's own on a managed host.
|
||||
if (Object.keys(updates).some((key) => key !== "pin")) {
|
||||
try {
|
||||
assertHostsUnlocked(ownedIds);
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const unauthorizedIds = hostIds.filter(
|
||||
(id: number) => !ownedIds.includes(id),
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "../../hosts/defaults/service.js";
|
||||
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
||||
import { readDefaultsReset } from "./host-bulk-routes.js";
|
||||
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||
|
||||
type HostDefaultsRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -678,6 +679,7 @@ export function registerHostDefaultsRoutes(
|
||||
const reset = readDefaultsReset(req.body);
|
||||
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
||||
try {
|
||||
assertHostsUnlocked([hostId]);
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
@@ -700,6 +702,7 @@ export function registerHostDefaultsRoutes(
|
||||
await changeHostOverrides([hostId], reset);
|
||||
res.json({ message: "Host reset to its defaults" });
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
databaseLogger.error("Failed to reset host defaults", error);
|
||||
res.status(500).json({ error: "Failed to reset host defaults" });
|
||||
}
|
||||
|
||||
@@ -16,6 +16,31 @@ import {
|
||||
folderCredentialIds,
|
||||
setFolderCredentialDefault,
|
||||
} from "../../hosts/defaults/service.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
hasHostLocks,
|
||||
sendHostLocked,
|
||||
} from "../../hosts/locks.js";
|
||||
|
||||
/** Refuses a folder change that would touch a locked host. */
|
||||
async function folderHostsUnlocked(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
res: Response,
|
||||
): Promise<boolean> {
|
||||
if (!hasHostLocks()) return true;
|
||||
const inFolder = await createCurrentHostFolderRepository().listHostsInFolder(
|
||||
userId,
|
||||
folderName,
|
||||
);
|
||||
try {
|
||||
assertHostsUnlocked(inFolder.map((host) => host.id));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
type HostFolderRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -86,6 +111,7 @@ export function registerHostFolderRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await folderHostsUnlocked(userId, oldName, res))) return;
|
||||
const { updatedHosts, updatedCredentials } =
|
||||
await createCurrentHostFolderRepository().renameFolder(
|
||||
userId,
|
||||
@@ -417,6 +443,7 @@ export function registerHostFolderRoutes(
|
||||
});
|
||||
|
||||
try {
|
||||
if (!(await folderHostsUnlocked(userId, folderName, res))) return;
|
||||
const hostFolderRepository = createCurrentHostFolderRepository();
|
||||
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
|
||||
userId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "../../hosts/ssh-options.js";
|
||||
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
|
||||
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
|
||||
import { hostLock } from "../../hosts/locks.js";
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
@@ -337,6 +338,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"sshPort",
|
||||
"protocolAuth",
|
||||
"jumpHosts",
|
||||
"lock",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]);
|
||||
@@ -439,8 +441,10 @@ export function transformHostResponse(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const shared = parseSharedSource(host.sharedSource);
|
||||
const lock = typeof host.id === "number" ? hostLock(host.id) : null;
|
||||
return {
|
||||
...host,
|
||||
...(lock ? { lock } : {}),
|
||||
sharedSource: undefined,
|
||||
localOnly: !!host.localOnly,
|
||||
sharedCopy: !!shared,
|
||||
|
||||
@@ -85,6 +85,11 @@ import {
|
||||
type PlannedProtocolAuth,
|
||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
manualCreateBlockedBy,
|
||||
requireManualCreate,
|
||||
requireUnlockedHost,
|
||||
} from "../../hosts/locks.js";
|
||||
import {
|
||||
mergeStoredSshOptions,
|
||||
parseTerminalConfig,
|
||||
@@ -157,6 +162,7 @@ router.post(
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.create"),
|
||||
requireDataAccess,
|
||||
requireManualCreate,
|
||||
requireHostEnrollmentAccessForPath,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
@@ -736,6 +742,7 @@ router.put(
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.edit"),
|
||||
requireDataAccess,
|
||||
requireUnlockedHost,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = Array.isArray(req.params.id)
|
||||
@@ -1205,6 +1212,27 @@ router.put(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/policy:
|
||||
* get:
|
||||
* summary: Host creation policy
|
||||
* description: Whether hosts can be added by hand, or a plugin manages them all.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: "{ manualCreate, blockedBy? } where blockedBy is the managing plugin's name."
|
||||
*/
|
||||
router.get("/policy", authenticateJWT, (_req: Request, res: Response) => {
|
||||
const blocked = manualCreateBlockedBy();
|
||||
res.json(
|
||||
blocked
|
||||
? { manualCreate: false, blockedBy: blocked.pluginName }
|
||||
: { manualCreate: true },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host:
|
||||
@@ -1702,6 +1730,7 @@ router.delete(
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.delete"),
|
||||
requireDataAccess,
|
||||
requireUnlockedHost,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = Array.isArray(req.params.id)
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
validateSettingsSave,
|
||||
type PluginSettingsScope,
|
||||
} from "../../plugins/settings.js";
|
||||
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
@@ -1878,6 +1879,18 @@ router.put(
|
||||
const inherit = Array.isArray(inheritRaw)
|
||||
? inheritRaw.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
// A managed host keeps its settings; only personal ones stay per user.
|
||||
const changesShared = [...Object.keys(written), ...inherit].some(
|
||||
(key) => !findField(manifest, "host", key)?.personal,
|
||||
);
|
||||
if (changesShared) {
|
||||
try {
|
||||
assertHostsUnlocked([hostId]);
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
hasHostLocks,
|
||||
requireUnlockedHost,
|
||||
sendHostLocked,
|
||||
} from "../../hosts/locks.js";
|
||||
import {
|
||||
SharedHostAuthOverrideService,
|
||||
SharedHostAuthOverrideServiceError,
|
||||
@@ -229,6 +235,7 @@ router.post(
|
||||
"/host/:id/share",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.share"),
|
||||
requireUnlockedHost,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const hostId = parseInt(id, 10);
|
||||
@@ -451,6 +458,14 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasHostLocks()) {
|
||||
const inFolder =
|
||||
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||
userId,
|
||||
folder,
|
||||
);
|
||||
assertHostsUnlocked(inFolder.map((host) => host.id));
|
||||
}
|
||||
const targets = parseShareTargets(req.body ?? {});
|
||||
if (!targets) {
|
||||
return res.status(400).json({
|
||||
@@ -613,6 +628,7 @@ router.post(
|
||||
hostResults,
|
||||
});
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
databaseLogger.error("Failed to share folder", error, {
|
||||
operation: "share_folder",
|
||||
folder,
|
||||
@@ -786,6 +802,7 @@ router.delete(
|
||||
router.patch(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
requireUnlockedHost,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
@@ -908,6 +925,7 @@ router.patch(
|
||||
router.delete(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
requireUnlockedHost,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
} from "../../database/routes/host-normalizers.js";
|
||||
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
|
||||
|
||||
/** A request the caller got wrong. Routes answer it with a 400. */
|
||||
export class ImportError extends Error {
|
||||
@@ -263,6 +264,19 @@ export function importResponse(results: ImportResults) {
|
||||
|
||||
export const MAX_IMPORT_HOSTS = 100;
|
||||
|
||||
/** Why an import row can't be written while a plugin manages hosts, if it can't. */
|
||||
function importLockError(existingId: number | undefined): string | null {
|
||||
if (!hasHostLocks()) return null;
|
||||
if (existingId === undefined) {
|
||||
const blocked = manualCreateBlockedBy();
|
||||
return blocked
|
||||
? `Hosts are managed by ${blocked.pluginName}, so new hosts can't be added`
|
||||
: null;
|
||||
}
|
||||
const lock = hostLock(existingId);
|
||||
return lock ? `This host is managed by ${lock.pluginName}` : null;
|
||||
}
|
||||
|
||||
/** Deletes what an atomic import created, newest first. */
|
||||
async function rollBackCreated(
|
||||
userId: string,
|
||||
@@ -446,6 +460,12 @@ export async function runBulkImport(
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
const locked = importLockError(existing?.id);
|
||||
if (locked) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${locked}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
@@ -745,6 +765,12 @@ export async function runSshConfigImport(
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
const locked = importLockError(existing?.id);
|
||||
if (locked) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host "${parsed[i].name}": ${locked}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Host locks: hosts a plugin manages itself (for example from a config file)
|
||||
* through ctx.hosts.locks.provide. Core refuses edits, deletes and share
|
||||
* changes on a locked host everywhere, except from the plugin that locks it,
|
||||
* and a provider can block adding hosts by hand. Pins and order stay with the
|
||||
* user.
|
||||
*/
|
||||
|
||||
import type { PluginHostLockProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
export interface HostLockInfo {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
provider: PluginHostLockProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export class HostLockedError extends Error {
|
||||
readonly status = 403;
|
||||
readonly code = "HOST_LOCKED";
|
||||
constructor(
|
||||
message: string,
|
||||
readonly lock: { pluginId: string; pluginName: string },
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HostLockedError";
|
||||
}
|
||||
}
|
||||
|
||||
export function provideHostLocks(
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
provider: PluginHostLockProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, pluginName, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearHostLocks(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
// A provider that throws never locks: a broken plugin must not freeze hosts.
|
||||
function safely<T>(entry: Entry, fallback: T, read: () => T): T {
|
||||
try {
|
||||
return read();
|
||||
} catch (error) {
|
||||
pluginLogger.warn("Host lock provider failed", {
|
||||
operation: "host_lock",
|
||||
pluginId: entry.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function hostLock(
|
||||
hostId: number,
|
||||
exemptPluginId?: string,
|
||||
): HostLockInfo | null {
|
||||
if (!Number.isInteger(hostId)) return null;
|
||||
for (const entry of providers.values()) {
|
||||
if (entry.pluginId === exemptPluginId) continue;
|
||||
if (!safely(entry, false, () => entry.provider.isLocked(hostId))) continue;
|
||||
return {
|
||||
pluginId: entry.pluginId,
|
||||
pluginName: entry.pluginName,
|
||||
note: safely(entry, null, () => entry.provider.note(hostId) ?? null),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasHostLocks(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
/** Throws HostLockedError for the first locked host. */
|
||||
export function assertHostsUnlocked(
|
||||
hostIds: Iterable<number>,
|
||||
exemptPluginId?: string,
|
||||
): void {
|
||||
if (providers.size === 0) return;
|
||||
for (const hostId of hostIds) {
|
||||
const lock = hostLock(Number(hostId), exemptPluginId);
|
||||
if (lock) {
|
||||
throw new HostLockedError(
|
||||
`This host is managed by ${lock.pluginName} and can't be changed here`,
|
||||
lock,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin that blocks adding hosts by hand, if any. */
|
||||
export function manualCreateBlockedBy(
|
||||
exemptPluginId?: string,
|
||||
): { pluginId: string; pluginName: string } | null {
|
||||
for (const entry of providers.values()) {
|
||||
if (entry.pluginId === exemptPluginId) continue;
|
||||
if (!safely(entry, true, () => entry.provider.allowManualCreate())) {
|
||||
return { pluginId: entry.pluginId, pluginName: entry.pluginName };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assertManualCreateAllowed(exemptPluginId?: string): void {
|
||||
const blocked = manualCreateBlockedBy(exemptPluginId);
|
||||
if (blocked) {
|
||||
throw new HostLockedError(
|
||||
`Hosts are managed by ${blocked.pluginName}, so new hosts can't be added here`,
|
||||
blocked,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Res = { status: (code: number) => { json: (body: unknown) => unknown } };
|
||||
type Next = () => void;
|
||||
|
||||
/** Route guard for creating hosts by hand. */
|
||||
export function requireManualCreate(_req: unknown, res: Res, next: Next) {
|
||||
try {
|
||||
assertManualCreateAllowed();
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
throw error;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Route guard for routes that change the host named by :id. */
|
||||
export function requireUnlockedHost(
|
||||
req: { params: Record<string, string | string[]> },
|
||||
res: Res,
|
||||
next: Next,
|
||||
) {
|
||||
const raw = req.params.id;
|
||||
const hostId = Number(Array.isArray(raw) ? raw[0] : raw);
|
||||
try {
|
||||
assertHostsUnlocked([hostId]);
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
throw error;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Sends a HostLockedError as a 403. False when the error is something else. */
|
||||
export function sendHostLocked(
|
||||
res: { status: (code: number) => { json: (body: unknown) => unknown } },
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (!(error instanceof HostLockedError)) return false;
|
||||
res.status(403).json({
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
lockedBy: error.lock.pluginId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -150,6 +150,37 @@ export function createPluginCredentials({
|
||||
}
|
||||
},
|
||||
|
||||
list: async () => {
|
||||
try {
|
||||
await assertCapability(pluginId, "credentials:use", declared);
|
||||
const userId = actingUser();
|
||||
await requireCorePermission(userId, "credentials.view");
|
||||
const { createCurrentCredentialRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const rows =
|
||||
await createCurrentCredentialRepository().listDecryptedByUserId(
|
||||
userId,
|
||||
);
|
||||
const list = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
username: row.username ?? null,
|
||||
authType: String(row.authType ?? ""),
|
||||
folder: row.folder ?? null,
|
||||
}));
|
||||
await audit("credentials_list", `${list.length} credentials`, {
|
||||
success: true,
|
||||
});
|
||||
return list;
|
||||
} catch (error) {
|
||||
await audit("credentials_list", "saved credentials", {
|
||||
success: false,
|
||||
errorMessage: errorText(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createSshKey: async (input) => {
|
||||
const details = `SSH key "${input?.name ?? ""}"`;
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
PluginShareableUser,
|
||||
PluginShareableRole,
|
||||
PluginHostJumpHost,
|
||||
PluginHostShareGrant,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
@@ -35,6 +36,11 @@ import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { pluginEvents, TOPICS } from "./events.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
assertManualCreateAllowed,
|
||||
provideHostLocks,
|
||||
} from "../hosts/locks.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -207,6 +213,8 @@ void inputKeysComplete;
|
||||
export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
row: Record<string, unknown>;
|
||||
settings: Record<string, Record<string, unknown>>;
|
||||
protocolAuth: Record<string, unknown> | null;
|
||||
pluginEnable: Record<string, boolean>;
|
||||
} {
|
||||
const unknown = Object.keys(input ?? {}).filter(
|
||||
(key) => !INPUT_KEYS.has(key),
|
||||
@@ -268,6 +276,32 @@ export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
? null
|
||||
: JSON.stringify(parseSshOptions(input.sshOptions)),
|
||||
);
|
||||
for (const key of [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"keyType",
|
||||
"sudoPassword",
|
||||
] as const) {
|
||||
const value = input[key];
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
throw new PluginHostInputError(`${key} must be a string`);
|
||||
}
|
||||
set(key, value === undefined ? undefined : value || null);
|
||||
}
|
||||
|
||||
let protocolAuth: Record<string, unknown> | null = null;
|
||||
if (input.protocolAuth !== undefined && input.protocolAuth !== null) {
|
||||
if (
|
||||
typeof input.protocolAuth !== "object" ||
|
||||
Array.isArray(input.protocolAuth)
|
||||
) {
|
||||
throw new PluginHostInputError(
|
||||
"protocolAuth must map protocol ids to their logins",
|
||||
);
|
||||
}
|
||||
protocolAuth = input.protocolAuth as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const settings: Record<string, Record<string, unknown>> = {};
|
||||
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
|
||||
@@ -288,7 +322,136 @@ export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
settings[pluginId] = values;
|
||||
}
|
||||
}
|
||||
return { row, settings };
|
||||
const pluginEnable: Record<string, boolean> = {};
|
||||
if (input.pluginEnable !== undefined && input.pluginEnable !== null) {
|
||||
if (
|
||||
typeof input.pluginEnable !== "object" ||
|
||||
Array.isArray(input.pluginEnable)
|
||||
) {
|
||||
throw new PluginHostInputError(
|
||||
"pluginEnable must map plugin ids to true or false",
|
||||
);
|
||||
}
|
||||
for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) {
|
||||
if (typeof enabled !== "boolean") {
|
||||
throw new PluginHostInputError(
|
||||
`pluginEnable.${pluginId} must be true or false`,
|
||||
);
|
||||
}
|
||||
pluginEnable[pluginId] = enabled;
|
||||
}
|
||||
}
|
||||
return { row, settings, protocolAuth, pluginEnable };
|
||||
}
|
||||
|
||||
/** Folds pluginEnable into each plugin's settings under its enableKey. */
|
||||
async function foldPluginEnable(
|
||||
settings: Record<string, Record<string, unknown>>,
|
||||
pluginEnable: Record<string, boolean>,
|
||||
): Promise<Record<string, Record<string, unknown>>> {
|
||||
const ids = Object.keys(pluginEnable);
|
||||
if (ids.length === 0) return settings;
|
||||
const { hostSettingsPlugins } =
|
||||
await import("../database/routes/host-plugin-settings.js");
|
||||
const running = hostSettingsPlugins();
|
||||
const out = { ...settings };
|
||||
for (const pluginId of ids) {
|
||||
const manifest = running.find((m) => m.id === pluginId);
|
||||
if (!manifest) {
|
||||
// checkHostPluginSettingsInput skips and logs a plugin that is not running.
|
||||
out[pluginId] = { ...out[pluginId] };
|
||||
continue;
|
||||
}
|
||||
const enableKey = manifest.contributes?.settings?.host?.enableKey;
|
||||
if (!enableKey) {
|
||||
throw new PluginHostInputError(`${pluginId} has no host switch`);
|
||||
}
|
||||
out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Writes a host's protocol logins; a credential the owner can't use is refused. */
|
||||
async function writeHostProtocolAuth(
|
||||
ownerId: string,
|
||||
hostId: number,
|
||||
protocolAuth: Record<string, unknown> | null,
|
||||
): Promise<void> {
|
||||
if (!protocolAuth) return;
|
||||
const { readProtocolAuthPayload, writeProtocolAuth } =
|
||||
await import("../hosts/protocol-auth/protocol-auth.js");
|
||||
const patch = readProtocolAuthPayload({ protocolAuth });
|
||||
if (!patch) return;
|
||||
const { findUsableCredential } =
|
||||
await import("../hosts/usable-credential.js");
|
||||
for (const [protocol, login] of patch) {
|
||||
const credentialId = Number(login?.credentialId);
|
||||
if (
|
||||
Number.isInteger(credentialId) &&
|
||||
credentialId > 0 &&
|
||||
!(await findUsableCredential(credentialId, ownerId))
|
||||
) {
|
||||
throw new PluginHostInputError(
|
||||
`protocolAuth.${protocol}: credential ${credentialId} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true });
|
||||
}
|
||||
|
||||
const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"];
|
||||
|
||||
/** Upserts one grant and snapshots the host's secrets for it. */
|
||||
async function grantHostShare(
|
||||
hostId: number,
|
||||
ownerId: string,
|
||||
grantedBy: string,
|
||||
target: PluginShareTarget,
|
||||
permissionLevel: PluginHostShareLevel,
|
||||
expiresAt: string | null,
|
||||
): Promise<number> {
|
||||
const { createCurrentRbacAccessRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const grant = await createCurrentRbacAccessRepository().upsertHostAccess({
|
||||
hostId,
|
||||
grantedBy,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
...(target.type === "user"
|
||||
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||
: { targetType: "role" as const, targetRoleId: target.id as number }),
|
||||
});
|
||||
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||
try {
|
||||
if (target.type === "user") {
|
||||
await secretsManager.snapshotForUser(
|
||||
grant.id,
|
||||
hostId,
|
||||
target.id as string,
|
||||
ownerId,
|
||||
);
|
||||
} else {
|
||||
await secretsManager.snapshotForRole(
|
||||
grant.id,
|
||||
hostId,
|
||||
target.id as number,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A snapshot failure never blocks the grant: the recipient can
|
||||
// still be prompted, or the owner can retry sharing later.
|
||||
}
|
||||
return grant.id;
|
||||
}
|
||||
|
||||
function hoursFromNow(durationHours: number | undefined): string | null {
|
||||
return durationHours && durationHours > 0
|
||||
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
}
|
||||
|
||||
function actingUser(): string {
|
||||
@@ -476,6 +639,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertManualCreateAllowed(pluginId);
|
||||
} catch (error) {
|
||||
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
|
||||
success: false,
|
||||
@@ -484,8 +648,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const { row, settings } = toHostWrite(host);
|
||||
const writes = await checkSettings(settings);
|
||||
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
|
||||
const writes = await checkSettings(
|
||||
await foldPluginEnable(settings, pluginEnable),
|
||||
);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||
@@ -504,6 +670,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
userId,
|
||||
columns,
|
||||
);
|
||||
await writeHostProtocolAuth(userId, created.id, protocolAuth);
|
||||
await applyDefaultsAfterHostWrite(created.id);
|
||||
await writeSettings(created.id, writes);
|
||||
await markSettingsOwn(created.id, writes);
|
||||
@@ -520,6 +687,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
): Promise<PluginHostRecord | null> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
} catch (error) {
|
||||
await audit("hosts_update", `host ${hostId}`, {
|
||||
success: false,
|
||||
@@ -528,8 +696,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const { row, settings } = toHostWrite(patch);
|
||||
const writes = await checkSettings(settings);
|
||||
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
|
||||
const writes = await checkSettings(
|
||||
await foldPluginEnable(settings, pluginEnable),
|
||||
);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||
@@ -560,6 +730,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
columns,
|
||||
);
|
||||
if (updated) {
|
||||
await writeHostProtocolAuth(userId, hostId, protocolAuth);
|
||||
await writeSettings(hostId, writes);
|
||||
await markSettingsOwn(hostId, writes);
|
||||
await applyDefaultsAfterHostWrite(hostId, {
|
||||
@@ -584,6 +755,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
delete: async (hostId: number): Promise<boolean> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
const userId = actingUser();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
@@ -631,6 +803,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
): Promise<PluginHostShareResult> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
} catch (error) {
|
||||
await audit("hosts_share", `host ${hostId}`, {
|
||||
success: false,
|
||||
@@ -660,10 +833,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
return result;
|
||||
}
|
||||
|
||||
const {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const ownerId =
|
||||
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||
hostId,
|
||||
@@ -677,56 +848,95 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
return { hostId, shared: false, reason: "owner" };
|
||||
}
|
||||
|
||||
const expiresAt =
|
||||
durationHours && durationHours > 0
|
||||
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
const rbacAccessRepository = createCurrentRbacAccessRepository();
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
const secretsManager = SharedHostSecretsManager.getInstance();
|
||||
|
||||
const expiresAt = hoursFromNow(durationHours);
|
||||
for (const target of targets) {
|
||||
const grant = await rbacAccessRepository.upsertHostAccess({
|
||||
await grantHostShare(
|
||||
hostId,
|
||||
grantedBy: userId,
|
||||
ownerId,
|
||||
userId,
|
||||
target,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
...(target.type === "user"
|
||||
? { targetType: "user" as const, targetUserId: target.id as string }
|
||||
: {
|
||||
targetType: "role" as const,
|
||||
targetRoleId: target.id as number,
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
if (target.type === "user") {
|
||||
await secretsManager.snapshotForUser(
|
||||
grant.id,
|
||||
hostId,
|
||||
target.id as string,
|
||||
ownerId,
|
||||
);
|
||||
} else {
|
||||
await secretsManager.snapshotForRole(
|
||||
grant.id,
|
||||
hostId,
|
||||
target.id as number,
|
||||
ownerId,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A snapshot failure never blocks the grant: the recipient can
|
||||
// still be prompted, or the owner can retry sharing later.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await audit("hosts_share", `host ${hostId}`, { success: true });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
|
||||
setShares: async (
|
||||
hostId: number,
|
||||
grants: PluginHostShareGrant[],
|
||||
): Promise<PluginHostShareResult> => {
|
||||
const fail = async (reason: string): Promise<PluginHostShareResult> => {
|
||||
await audit("hosts_set_shares", `host ${hostId}`, {
|
||||
success: false,
|
||||
errorMessage: reason,
|
||||
});
|
||||
return { hostId, shared: false, reason };
|
||||
};
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
} catch (error) {
|
||||
await fail(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
if (!Array.isArray(grants)) {
|
||||
throw new PluginHostInputError("grants must be a list");
|
||||
}
|
||||
for (const grant of grants) {
|
||||
const type = grant?.target?.type;
|
||||
if (
|
||||
(type !== "user" && type !== "role") ||
|
||||
!SHARE_LEVELS.includes(grant.permissionLevel)
|
||||
) {
|
||||
throw new PluginHostInputError(
|
||||
"Each grant needs a user or role target and a share level",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userId = actingUser();
|
||||
const {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
const ownerId =
|
||||
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
|
||||
if (ownerId !== userId) return fail("forbidden");
|
||||
if (
|
||||
grants.some((g) => g.target.type === "user" && g.target.id === userId)
|
||||
) {
|
||||
return fail("owner");
|
||||
}
|
||||
|
||||
const kept = new Set<number>();
|
||||
for (const grant of grants) {
|
||||
kept.add(
|
||||
await grantHostShare(
|
||||
hostId,
|
||||
ownerId,
|
||||
userId,
|
||||
grant.target,
|
||||
grant.permissionLevel,
|
||||
hoursFromNow(grant.durationHours),
|
||||
),
|
||||
);
|
||||
}
|
||||
const repository = createCurrentRbacAccessRepository();
|
||||
for (const existing of await repository.listHostAccess(hostId)) {
|
||||
if (!kept.has(existing.id)) {
|
||||
await repository.revokeHostAccess(existing.id, hostId);
|
||||
}
|
||||
}
|
||||
const { pruneHostGrants } = await import("../database/routes/rbac.js");
|
||||
await pruneHostGrants(hostId);
|
||||
|
||||
await audit("hosts_set_shares", `host ${hostId}`, { success: true });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
|
||||
listUsers: async (): Promise<PluginShareableUser[]> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
@@ -810,6 +1020,27 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
return hostSessionStatus.hasActiveSession(hostId);
|
||||
},
|
||||
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:lock")) {
|
||||
throw capabilityRefused(pluginId, "hosts:lock");
|
||||
}
|
||||
const remove = provideHostLocks(
|
||||
pluginId,
|
||||
manifest.name || pluginId,
|
||||
provider,
|
||||
);
|
||||
void audit("host_locks_provide", `${pluginId} manages hosts`, {
|
||||
success: true,
|
||||
});
|
||||
const drop = bag?.add(remove, "host lock provider");
|
||||
return () => {
|
||||
drop?.();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:activity-provide")) {
|
||||
|
||||
@@ -11,6 +11,12 @@ import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { getEntity } from "../../plugins/sync-registry.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
hasHostLocks,
|
||||
hostLock,
|
||||
manualCreateBlockedBy,
|
||||
} from "../../hosts/locks.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
@@ -48,6 +54,22 @@ export type PushResult =
|
||||
|
||||
export const MAX_PUSH_OPS = 500;
|
||||
|
||||
/** A host a plugin manages can't be changed from a desktop either. */
|
||||
async function hostPushLocked(
|
||||
userId: string,
|
||||
syncId: string,
|
||||
exists: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!hasHostLocks()) return false;
|
||||
if (!exists) return manualCreateBlockedBy() !== null;
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
|
||||
syncId,
|
||||
userId,
|
||||
);
|
||||
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
|
||||
}
|
||||
|
||||
function isPushOp(value: unknown): value is PushOp {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const op = value as Record<string, unknown>;
|
||||
@@ -166,6 +188,13 @@ export async function applyPush(
|
||||
results.set(key, { ...base, status: "rejected", reason: "permission" });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
op.entityType === "hosts" &&
|
||||
(await hostPushLocked(userId, op.syncId, exists))
|
||||
) {
|
||||
results.set(key, { ...base, status: "rejected", reason: "locked" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (op.op === "delete") {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express, { type RequestHandler } from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
updates: [] as Array<{ ids: number[]; update: Record<string, unknown> }>,
|
||||
renamed: 0,
|
||||
folderHosts: [] as Array<{ id: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||
pluginLogger: { warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { batched: (fn: RequestHandler) => fn },
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/recompute.js", () => ({
|
||||
recompute: async () => ({ changedHostIds: [] }),
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/service.js", () => ({
|
||||
folderCredentialIds: async () => [],
|
||||
setFolderCredentialDefault: async () => {},
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
setHostPluginEnabled: async () => true,
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => ({
|
||||
listBulkUpdateState: async (_userId: string, ids: number[]) =>
|
||||
ids.map((id) => ({ id })),
|
||||
updateManyForUser: async (
|
||||
_userId: string,
|
||||
ids: number[],
|
||||
update: Record<string, unknown>,
|
||||
) => {
|
||||
state.updates.push({ ids, update });
|
||||
},
|
||||
}),
|
||||
createCurrentHostFolderRepository: () => ({
|
||||
listHostsInFolder: async () => state.folderHosts,
|
||||
renameFolder: async () => {
|
||||
state.renamed++;
|
||||
return { updatedHosts: 1, updatedCredentials: 0 };
|
||||
},
|
||||
}),
|
||||
createCurrentFolderAccessRepository: () => ({
|
||||
renameFolder: async () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { registerHostBulkRoutes } =
|
||||
await import("../../../database/routes/host-bulk-routes.js");
|
||||
const { registerHostFolderRoutes } =
|
||||
await import("../../../database/routes/host-folder-routes.js");
|
||||
const { clearHostLocks, provideHostLocks } =
|
||||
await import("../../../hosts/locks.js");
|
||||
|
||||
const pass: RequestHandler = (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId = "owner";
|
||||
next();
|
||||
};
|
||||
|
||||
function app() {
|
||||
const router = express.Router();
|
||||
registerHostBulkRoutes(router, pass, pass, pass);
|
||||
registerHostFolderRoutes(router, {
|
||||
authenticateJWT: pass,
|
||||
requireViewPermission: pass,
|
||||
requireEditPermission: pass,
|
||||
requireDeletePermission: pass,
|
||||
requireCredentialEditPermission: pass,
|
||||
requireDataAccess: pass,
|
||||
});
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
server.use(router);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.updates = [];
|
||||
state.renamed = 0;
|
||||
state.folderHosts = [];
|
||||
provideHostLocks("cfg", "Config", {
|
||||
isLocked: (hostId) => hostId === 2,
|
||||
note: () => null,
|
||||
allowManualCreate: () => true,
|
||||
});
|
||||
});
|
||||
afterEach(() => clearHostLocks());
|
||||
|
||||
describe("routes on locked hosts", () => {
|
||||
it("still lets a user pin a locked host", async () => {
|
||||
await request(app())
|
||||
.patch("/bulk-update")
|
||||
.send({ hostIds: [1, 2], updates: { pin: true } })
|
||||
.expect(200);
|
||||
expect(state.updates).toEqual([{ ids: [1, 2], update: { pin: true } }]);
|
||||
});
|
||||
|
||||
it("refuses a bulk change that touches a locked host", async () => {
|
||||
const res = await request(app())
|
||||
.patch("/bulk-update")
|
||||
.send({ hostIds: [1, 2], updates: { folder: "Moved" } })
|
||||
.expect(403);
|
||||
expect(res.body).toMatchObject({ code: "HOST_LOCKED" });
|
||||
expect(state.updates).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses renaming a folder that holds a locked host", async () => {
|
||||
state.folderHosts = [{ id: 1 }, { id: 2 }];
|
||||
await request(app())
|
||||
.put("/folders/rename")
|
||||
.send({ oldName: "Prod", newName: "Live" })
|
||||
.expect(403);
|
||||
expect(state.renamed).toBe(0);
|
||||
|
||||
state.folderHosts = [{ id: 1 }];
|
||||
await request(app())
|
||||
.put("/folders/rename")
|
||||
.send({ oldName: "Prod", newName: "Live" })
|
||||
.expect(200);
|
||||
expect(state.renamed).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: { warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const {
|
||||
assertHostsUnlocked,
|
||||
assertManualCreateAllowed,
|
||||
clearHostLocks,
|
||||
hasHostLocks,
|
||||
hostLock,
|
||||
HostLockedError,
|
||||
manualCreateBlockedBy,
|
||||
provideHostLocks,
|
||||
requireManualCreate,
|
||||
requireUnlockedHost,
|
||||
} = await import("../../hosts/locks.js");
|
||||
|
||||
const provider = (locked: number[], allowManualCreate = true) => ({
|
||||
isLocked: (hostId: number) => locked.includes(hostId),
|
||||
note: (hostId: number) => `id ${hostId}`,
|
||||
allowManualCreate: () => allowManualCreate,
|
||||
});
|
||||
|
||||
function fakeRes() {
|
||||
const res = {
|
||||
code: 0,
|
||||
body: null as unknown,
|
||||
status(code: number) {
|
||||
res.code = code;
|
||||
return { json: (body: unknown) => (res.body = body) };
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
afterEach(() => clearHostLocks());
|
||||
|
||||
describe("host locks", () => {
|
||||
it("reports nothing without providers", () => {
|
||||
expect(hasHostLocks()).toBe(false);
|
||||
expect(hostLock(1)).toBeNull();
|
||||
expect(() => assertHostsUnlocked([1, 2])).not.toThrow();
|
||||
expect(manualCreateBlockedBy()).toBeNull();
|
||||
});
|
||||
|
||||
it("names the plugin and note for a locked host", () => {
|
||||
provideHostLocks("cfg", "Config", provider([3]));
|
||||
expect(hostLock(3)).toEqual({
|
||||
pluginId: "cfg",
|
||||
pluginName: "Config",
|
||||
note: "id 3",
|
||||
});
|
||||
expect(hostLock(4)).toBeNull();
|
||||
expect(() => assertHostsUnlocked([4, 3])).toThrow(HostLockedError);
|
||||
});
|
||||
|
||||
it("lets the locking plugin through", () => {
|
||||
provideHostLocks("cfg", "Config", provider([3], false));
|
||||
expect(() => assertHostsUnlocked([3], "cfg")).not.toThrow();
|
||||
expect(() => assertManualCreateAllowed("cfg")).not.toThrow();
|
||||
expect(() => assertManualCreateAllowed()).toThrow(/managed by Config/);
|
||||
});
|
||||
|
||||
it("drops a provider when removed", () => {
|
||||
const remove = provideHostLocks("cfg", "Config", provider([3], false));
|
||||
remove();
|
||||
expect(hostLock(3)).toBeNull();
|
||||
expect(manualCreateBlockedBy()).toBeNull();
|
||||
});
|
||||
|
||||
it("never locks when a provider throws", () => {
|
||||
provideHostLocks("cfg", "Config", {
|
||||
isLocked: () => {
|
||||
throw new Error("broken");
|
||||
},
|
||||
note: () => null,
|
||||
allowManualCreate: () => {
|
||||
throw new Error("broken");
|
||||
},
|
||||
});
|
||||
expect(hostLock(1)).toBeNull();
|
||||
expect(manualCreateBlockedBy()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock route guards", () => {
|
||||
it("answers 403 HOST_LOCKED for a locked :id", () => {
|
||||
provideHostLocks("cfg", "Config", provider([7]));
|
||||
const res = fakeRes();
|
||||
const next = vi.fn();
|
||||
requireUnlockedHost({ params: { id: "7" } }, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.code).toBe(403);
|
||||
expect(res.body).toMatchObject({ code: "HOST_LOCKED", lockedBy: "cfg" });
|
||||
|
||||
requireUnlockedHost({ params: { id: "8" } }, fakeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks manual creates only while a provider says so", () => {
|
||||
const next = vi.fn();
|
||||
requireManualCreate({}, fakeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
|
||||
provideHostLocks("cfg", "Config", provider([], false));
|
||||
const res = fakeRes();
|
||||
requireManualCreate({}, res, next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res.code).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -461,6 +461,40 @@ describe("ctx.credentials.registerSecretResolver", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.credentials.list", () => {
|
||||
beforeEach(() => {
|
||||
state.userCredentials.set("owner", [
|
||||
{ id: 3, name: "pw", authType: "password", password: "secret" },
|
||||
{ id: 4, name: "key", authType: "key", privateKey: "PRIVATE KEY" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses without credentials:use", async () => {
|
||||
grants.set("demo", []);
|
||||
const ctx = contextFor([]);
|
||||
await expect(
|
||||
runAsActor("owner", "request", () => ctx.credentials.list()),
|
||||
).rejects.toThrow(/credentials:use/);
|
||||
});
|
||||
|
||||
it("lists the actor's credentials without secrets", async () => {
|
||||
grants.set("demo", ["credentials:use"]);
|
||||
const ctx = contextFor(["credentials:use"]);
|
||||
const list = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.list(),
|
||||
);
|
||||
expect(list).toEqual([
|
||||
{ id: 3, name: "pw", username: null, authType: "password", folder: null },
|
||||
{ id: 4, name: "key", username: null, authType: "key", folder: null },
|
||||
]);
|
||||
expect(JSON.stringify(list)).not.toMatch(/secret|PRIVATE/);
|
||||
expect(auditEntries.at(-1)).toMatchObject({
|
||||
action: "plugin_credentials_list",
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.credentials.listSshKeys", () => {
|
||||
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
|
||||
const derived = (() => {
|
||||
|
||||
@@ -27,6 +27,9 @@ const h = vi.hoisted(() => ({
|
||||
statusPortsRemoved: [] as string[],
|
||||
userPermissions: new Set<string>(),
|
||||
deleted: [] as Array<{ userId: string; hostId: number }>,
|
||||
existingAccess: [] as Array<{ id: number }>,
|
||||
revoked: [] as number[],
|
||||
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -85,6 +88,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
h.upserts.push(input);
|
||||
return { id: h.upserts.length, created: true };
|
||||
},
|
||||
listHostAccess: async () => h.existingAccess,
|
||||
revokeHostAccess: async (accessId: number) => {
|
||||
h.revoked.push(accessId);
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
listAll: async () => h.users,
|
||||
@@ -145,6 +152,13 @@ vi.mock("../../plugins/host-status-provider.js", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => [
|
||||
{
|
||||
id: "metrics",
|
||||
contributes: { settings: { host: { enableKey: "enableMetrics" } } },
|
||||
},
|
||||
{ id: "noswitch", contributes: { settings: { host: { fields: [] } } } },
|
||||
],
|
||||
loadHostPluginSettings: async (hostIds: number[]) =>
|
||||
new Map(
|
||||
hostIds.map((id) => [
|
||||
@@ -198,6 +212,25 @@ vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../database/routes/rbac.js", () => ({
|
||||
pruneHostGrants: async () => 0,
|
||||
}));
|
||||
vi.mock("../../hosts/usable-credential.js", () => ({
|
||||
findUsableCredential: async (credentialId: number) =>
|
||||
credentialId === 5 ? { id: 5 } : null,
|
||||
}));
|
||||
vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({
|
||||
readProtocolAuthPayload: (data: { protocolAuth: Record<string, unknown> }) =>
|
||||
new Map(Object.entries(data.protocolAuth)),
|
||||
writeProtocolAuth: async (
|
||||
_ownerId: string,
|
||||
hostId: number,
|
||||
patch: Map<string, unknown>,
|
||||
) => {
|
||||
h.protocolWrites.push({ hostId, protocols: [...patch.keys()] });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/delete-host.js", () => ({
|
||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||
h.deleted.push({ userId, hostId });
|
||||
@@ -209,6 +242,8 @@ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
const { clearHostLocks, hostLock, provideHostLocks } =
|
||||
await import("../../hosts/locks.js");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
@@ -236,6 +271,10 @@ beforeEach(() => {
|
||||
h.roles = [];
|
||||
h.userPermissions = new Set();
|
||||
h.deleted = [];
|
||||
h.existingAccess = [];
|
||||
h.revoked = [];
|
||||
h.protocolWrites = [];
|
||||
clearHostLocks();
|
||||
});
|
||||
|
||||
describe("ctx.hosts", () => {
|
||||
@@ -705,3 +744,220 @@ describe("ctx.hosts.status", () => {
|
||||
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts secrets and protocol logins", () => {
|
||||
it("stores secrets on create and writes protocol logins", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const created = await hosts.create({
|
||||
name: "rdp",
|
||||
ip: "10.0.0.9",
|
||||
port: 3389,
|
||||
username: "",
|
||||
authType: "password",
|
||||
password: "s3cret",
|
||||
sudoPassword: "",
|
||||
protocolAuth: { rdp: { authType: "direct", username: "admin" } },
|
||||
});
|
||||
expect(h.created[0]).toMatchObject({
|
||||
password: "s3cret",
|
||||
sudoPassword: null,
|
||||
});
|
||||
expect(created).not.toHaveProperty("password");
|
||||
expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]);
|
||||
});
|
||||
|
||||
it("refuses a protocol credential the owner cannot use", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(
|
||||
hosts.create({
|
||||
name: "vnc",
|
||||
ip: "10.0.0.9",
|
||||
port: 5900,
|
||||
username: "",
|
||||
authType: "password",
|
||||
protocolAuth: { vnc: { authType: "credential", credentialId: 6 } },
|
||||
}),
|
||||
).rejects.toThrow(/credential 6/);
|
||||
});
|
||||
|
||||
it("refuses a secret that is not a string", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow(
|
||||
/password/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts pluginEnable", () => {
|
||||
it("writes each plugin's switch under its enableKey", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.importRows = [];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await hosts.create({
|
||||
name: "n",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
pluginEnable: { metrics: false },
|
||||
});
|
||||
expect(h.importRows).toEqual([
|
||||
{ hostId: 99, row: { metrics: { enableMetrics: false } } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a plugin with no switch", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(
|
||||
hosts.update(5, { pluginEnable: { noswitch: true } }),
|
||||
).rejects.toThrow(/no host switch/);
|
||||
expect(h.updates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.setShares", () => {
|
||||
it("upserts the listed grants and revokes the rest", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.existingAccess = [{ id: 1 }, { id: 7 }];
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const result = await hosts.setShares(3, [
|
||||
{ target: { type: "role", id: 2 }, permissionLevel: "connect" },
|
||||
]);
|
||||
expect(result).toEqual({ hostId: 3, shared: true });
|
||||
expect(h.upserts).toEqual([
|
||||
expect.objectContaining({
|
||||
hostId: 3,
|
||||
targetType: "role",
|
||||
targetRoleId: 2,
|
||||
permissionLevel: "connect",
|
||||
expiresAt: null,
|
||||
}),
|
||||
]);
|
||||
expect(h.revoked).toEqual([7]);
|
||||
});
|
||||
|
||||
it("only works for the host owner", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.ownerId = "someone-else";
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(await hosts.setShares(3, [])).toEqual({
|
||||
hostId: 3,
|
||||
shared: false,
|
||||
reason: "forbidden",
|
||||
});
|
||||
expect(h.revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a bad level", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(
|
||||
hosts.setShares(3, [
|
||||
{ target: { type: "user", id: "u2" }, permissionLevel: "own" as never },
|
||||
]),
|
||||
).rejects.toThrow(/share level/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.locks", () => {
|
||||
const provider = (locked: number[], allowManualCreate = true) => ({
|
||||
isLocked: (hostId: number) => locked.includes(hostId),
|
||||
note: () => "From hosts.yaml",
|
||||
allowManualCreate: () => allowManualCreate,
|
||||
});
|
||||
|
||||
it("needs hosts:lock", () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.locks.provide(provider([1]))).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("locks hosts until the plugin removes its provider", () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:lock"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const remove = hosts.locks.provide(provider([4]));
|
||||
expect(hostLock(4)).toEqual({
|
||||
pluginId: "fixture",
|
||||
pluginName: "Fixture",
|
||||
note: "From hosts.yaml",
|
||||
});
|
||||
expect(hostLock(5)).toBeNull();
|
||||
remove();
|
||||
expect(hostLock(4)).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks other plugins on a locked host but not the plugin that locks it", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
h.updateResult = { id: 4, name: "x" };
|
||||
h.userPermissions = new Set(["hosts.delete"]);
|
||||
provideHostLocks("hosts-file", "Hosts File", provider([4], false));
|
||||
const other = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(other.update(4, { name: "y" })).rejects.toThrow(
|
||||
/managed by Hosts File/,
|
||||
);
|
||||
await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/);
|
||||
await expect(
|
||||
other.share(4, [{ type: "user", id: "u2" }], "view"),
|
||||
).rejects.toThrow(/managed by Hosts File/);
|
||||
await expect(
|
||||
other.create({
|
||||
name: "n",
|
||||
ip: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
}),
|
||||
).rejects.toThrow(/new hosts can't be added/);
|
||||
expect(h.updates).toHaveLength(0);
|
||||
expect(h.deleted).toHaveLength(0);
|
||||
|
||||
const owner = createPluginHosts({
|
||||
manifest: {
|
||||
id: "hosts-file",
|
||||
name: "Hosts File",
|
||||
version: "1.0.0",
|
||||
capabilities: ["hosts:write"],
|
||||
} as never,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await owner.update(4, { name: "y" });
|
||||
expect(h.updates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,13 @@ export interface JumpHost {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
/** The plugin that manages a host, from ctx.hosts.locks. */
|
||||
export interface HostLock {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export type Host = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -116,6 +123,8 @@ export type Host = {
|
||||
ownerUsername?: string;
|
||||
/** A read-only copy of a host shared with the linked account. */
|
||||
sharedCopy?: boolean;
|
||||
/** Set when a plugin manages this host: read only, pins and order still work. */
|
||||
lock?: HostLock;
|
||||
/** Desktop only: kept on this device, never synced to the server. */
|
||||
localOnly?: boolean;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
HostLock,
|
||||
HostProtocolAuthSummary,
|
||||
HostSshOptions,
|
||||
HostTerminalConfig,
|
||||
@@ -104,6 +105,8 @@ export type Host = {
|
||||
* linked to. It arrives through sync and is managed on the server.
|
||||
*/
|
||||
sharedCopy?: boolean;
|
||||
/** Set when a plugin manages this host: read only, pins and order still work. */
|
||||
lock?: HostLock;
|
||||
/** Desktop only: kept on this device, never synced to the server. */
|
||||
localOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -86,6 +86,21 @@ export async function bulkUpdateSSHHosts(
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostPolicy {
|
||||
manualCreate: boolean;
|
||||
/** The plugin that manages hosts, when hosts can't be added by hand. */
|
||||
blockedBy?: string;
|
||||
}
|
||||
|
||||
export async function getHostPolicy(): Promise<HostPolicy> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/policy");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "load host policy");
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderSSHHosts(
|
||||
positions: { id: number; sortOrder: number }[],
|
||||
): Promise<{ updated: number }> {
|
||||
|
||||
@@ -548,6 +548,9 @@
|
||||
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
|
||||
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
|
||||
"failedToMoveHosts": "Failed to move hosts",
|
||||
"managedBanner": "This host is managed by {{plugin}}. Change it there. You can still pin and reorder it.",
|
||||
"managedTooltip": "Managed by {{plugin}}",
|
||||
"addBlocked": "Hosts are managed by {{plugin}}, so new hosts can't be added here.",
|
||||
"failedToReorder": "Failed to reorder",
|
||||
"expandAll": "Expand all folders",
|
||||
"collapseAll": "Collapse all folders",
|
||||
@@ -1212,6 +1215,10 @@
|
||||
"title": "Store recent activity",
|
||||
"consequence": "Becomes the store for the recent activity list of every user."
|
||||
},
|
||||
"hosts:lock": {
|
||||
"title": "Manage hosts as read only",
|
||||
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Open desktop windows",
|
||||
"consequence": "Only in the Termix desktop app."
|
||||
|
||||
@@ -879,6 +879,8 @@ export {
|
||||
updateSSHHost,
|
||||
bulkUpdateSSHHosts,
|
||||
reorderSSHHosts,
|
||||
getHostPolicy,
|
||||
type HostPolicy,
|
||||
deleteSSHHost,
|
||||
getSSHHostById,
|
||||
testProxyConnection,
|
||||
|
||||
@@ -402,6 +402,7 @@ export {
|
||||
} from "@/types/host-sidebar-preferences";
|
||||
export type {
|
||||
HostData,
|
||||
HostLock,
|
||||
HostProtocolAuthInput,
|
||||
ProxyNode,
|
||||
SSHHost,
|
||||
@@ -656,6 +657,8 @@ export {
|
||||
getHostAccess,
|
||||
getHostAuthOverride,
|
||||
getHostPassword,
|
||||
getHostPolicy,
|
||||
type HostPolicy,
|
||||
getHostSidebarPreferences,
|
||||
getLogLevel,
|
||||
getPasswordLoginAllowed,
|
||||
|
||||
@@ -74,6 +74,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
localOnly: !!h.localOnly,
|
||||
sharedCopy: !!h.sharedCopy,
|
||||
...(h.lock ? { lock: h.lock } : {}),
|
||||
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
|
||||
sshPort:
|
||||
h.sshPort ??
|
||||
|
||||
Reference in new issue
Block a user