diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f711436b..710e4d73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index da2b8f899..a2f5f82ed 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -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. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 0d39d449b..ead6effbe 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -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 | diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 46a0afb1a..e217295f7 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -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": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 1913584aa..8bc92658a 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -167,6 +167,7 @@ "network:probe", "hosts:status-provide", "hosts:activity-provide", + "hosts:lock", "desktop:window", "desktop:local", "users:impersonate", diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 66cf20a9e..e04e30761 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -698,6 +698,36 @@ export interface PluginHostCreateInput { sshOptions?: HostSshOptions | null; /** Host settings by plugin id, the shape a Termix export carries. */ pluginSettings?: Record>; + /** + * 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; + /** + * 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; +} + +/** 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; } /** 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; + /** + * 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; /** Users the caller may pick as a share target, for building a picker UI. */ listUsers: () => Promise; /** 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; + /** + * 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; /** * 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 diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index e71cd39e2..1ca8eadbc 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -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"), diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index b02ec8622..15a3a7533 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -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( diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 130d5b7cc..aaa8c2c28 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -76,6 +76,9 @@ import type { PluginHostProbeTarget, PluginHostStatusEntry, PluginActivityProvider, + PluginHostLockProvider, + PluginCredentialSummary, + PluginHostShareGrant, PluginHostStatusProvider, PluginNotification, PluginNotificationChannel, @@ -140,6 +143,8 @@ export interface FakeContextOptions { protocolTargets?: Record; /** 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(); 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; /** 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(); diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index 205f955d2..32801f057 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -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 getHostEditorSection (value): (id: string) => HostEditorSectionDef getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise +getHostPolicy (value): () => Promise getHostSidebarPreferences (value): () => Promise getHostTags (value): () => Promise getLiveHostStatus (value): (hostId: number) => StatusValue diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 6f60f4282..9f957824d 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -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", diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index c89df15f2..4055704b0 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -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), ); diff --git a/src/backend/database/routes/host-defaults-routes.ts b/src/backend/database/routes/host-defaults-routes.ts index 16d8358a9..fd8c57664 100644 --- a/src/backend/database/routes/host-defaults-routes.ts +++ b/src/backend/database/routes/host-defaults-routes.ts @@ -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" }); } diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts index d0775f577..7da515c81 100644 --- a/src/backend/database/routes/host-folder-routes.ts +++ b/src/backend/database/routes/host-folder-routes.ts @@ -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 { + 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, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 54beeafbe..005ac2aaf 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -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, ): Record { 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, diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index cd6114d7a..ca768aa6c 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -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) diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index dcbf4415f..03702851f 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -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, diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index f5b6c11a4..2f88ccc04 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -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) diff --git a/src/backend/hosts/import/host-import.ts b/src/backend/hosts/import/host-import.ts index 340e43200..d9acb3b8c 100644 --- a/src/backend/hosts/import/host-import.ts +++ b/src/backend/hosts/import/host-import.ts @@ -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 = { userId, diff --git a/src/backend/hosts/locks.ts b/src/backend/hosts/locks.ts new file mode 100644 index 000000000..8685a0c25 --- /dev/null +++ b/src/backend/hosts/locks.ts @@ -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(); + +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(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, + 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 }, + 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; +} diff --git a/src/backend/plugins/ctx-credentials.ts b/src/backend/plugins/ctx-credentials.ts index cfe5e1532..ed2fdd7a7 100644 --- a/src/backend/plugins/ctx-credentials.ts +++ b/src/backend/plugins/ctx-credentials.ts @@ -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 { diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index 6b5c7f14a..c86b08669 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -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; settings: Record>; + protocolAuth: Record | null; + pluginEnable: Record; } { 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 | 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; + } const settings: Record> = {}; 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 = {}; + 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>, + pluginEnable: Record, +): Promise>> { + 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 | null, +): Promise { + 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 { + 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 => { 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 => { 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 => { 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 => { 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 => { + const fail = async (reason: string): Promise => { + 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(); + 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 => { 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")) { diff --git a/src/backend/sync/server/push.ts b/src/backend/sync/server/push.ts index d192e3dfc..4cfc19579 100644 --- a/src/backend/sync/server/push.ts +++ b/src/backend/sync/server/push.ts @@ -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 { + 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; @@ -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") { diff --git a/src/backend/tests/database/routes/host-locked-routes.test.ts b/src/backend/tests/database/routes/host-locked-routes.test.ts new file mode 100644 index 000000000..b1dcbf206 --- /dev/null +++ b/src/backend/tests/database/routes/host-locked-routes.test.ts @@ -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 }>, + 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, + ) => { + 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); + }); +}); diff --git a/src/backend/tests/hosts/locks.test.ts b/src/backend/tests/hosts/locks.test.ts new file mode 100644 index 000000000..7efe6d1fb --- /dev/null +++ b/src/backend/tests/hosts/locks.test.ts @@ -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); + }); +}); diff --git a/src/backend/tests/plugins/ctx-credentials.test.ts b/src/backend/tests/plugins/ctx-credentials.test.ts index 3eb8bc505..2f07a00b3 100644 --- a/src/backend/tests/plugins/ctx-credentials.test.ts +++ b/src/backend/tests/plugins/ctx-credentials.test.ts @@ -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 = (() => { diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index b5e8f4215..c572fdbb2 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -27,6 +27,9 @@ const h = vi.hoisted(() => ({ statusPortsRemoved: [] as string[], userPermissions: new Set(), 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 }) => + new Map(Object.entries(data.protocolAuth)), + writeProtocolAuth: async ( + _ownerId: string, + hostId: number, + patch: Map, + ) => { + 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); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 7606a6992..bc6f89fa4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 84e0d52d3..cfb58ad81 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -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; }; diff --git a/src/ui/api/ssh-host-management-api.ts b/src/ui/api/ssh-host-management-api.ts index 7e3daf8ae..2ea8fda4f 100644 --- a/src/ui/api/ssh-host-management-api.ts +++ b/src/ui/api/ssh-host-management-api.ts @@ -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 { + 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 }> { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 390501cf8..7bb9db0fa 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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." diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 121e1b675..e5b27a720 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -879,6 +879,8 @@ export { updateSSHHost, bulkUpdateSSHHosts, reorderSSHHosts, + getHostPolicy, + type HostPolicy, deleteSSHHost, getSSHHostById, testProxyConnection, diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts index 84e9f69dc..209504c2f 100644 --- a/src/ui/plugin-host/sdk-ui.ts +++ b/src/ui/plugin-host/sdk-ui.ts @@ -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, diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index e4235f205..b6e6b42ed 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -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 ??