/** * SSH HTTP layer — Phase 5 of the SSH tool integration plan * (docs/superpowers/plans/2026-05-12-ssh-tool-integration.md). * * Two router factories: * createSshUserRouter — `/api/ssh/connections` + `/api/ssh/grants` * createSshAdminRouter — `/api/ssh/admin/*` * * Both are mounted from src/bridge/server.ts ONLY when `ssh.enabled=true` * AND `MCP_ENCRYPTION_KEY` is configured (the master key gates the envelope- * encrypted DEKs in Phase 1a). When SSH is disabled, no router is mounted — * the endpoints simply do not exist (no 404 distinction from a missing route). * * Design conventions: * - Manual JSON validation (no zod) for consistency with pieces-api / mcp-api. * - Reason gating: every admin write path requires `body.reason` >= 8 chars. * - Audit logging: every admin write uses beginAudit → action → completeAudit * (or beginAndComplete for synchronous DB-only changes); user actions audit * only the privileged ones (test, verify, replace). * - Maintenance mode: when active, all write endpoints — user AND admin — * return 503 with `Retry-After: 30`. Read endpoints stay available. * - Buffer hygiene: decrypted private keys are passed to ssh-session and then * cleared on every code path (success, error, validation failure). * - Response shaping: `presentConnection()` strips encrypted blob fields and * the pending host-key token (token only returned on test / observation). */ import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express'; import type Database from 'better-sqlite3'; import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js'; import type { SshGrant, SshGrantsRepo, SshGrantSubjectType } from '../ssh/grants-repo.js'; import type { SshAuditRepo, SshAuditRow } from '../ssh/audit-repo.js'; import type { SshAbuseRepo } from '../ssh/abuse-repo.js'; import type { SshAccessResolver } from '../ssh/access.js'; import type { MaintenanceController } from '../ssh/maintenance.js'; import type { AdminRateLimiter } from '../ssh/admin-rate-limit.js'; import { logger } from '../logger.js'; // ────────────────────────────────────────────────────────────────────── // Types // ────────────────────────────────────────────────────────────────────── export interface SshTesterArgs { connection: SshConnection; decryptedKey: Buffer; passphrase: Buffer | null; timeoutMs: number; } export type SshTesterVerdict = 'first_observe' | 'mismatch' | 'pass' | 'alg_not_allowed'; export interface SshTesterResult { /** SHA256 fingerprint of the observed host key. */ fingerprint: string; /** Base64 of the observed host key (wire format). */ hostKeyB64: string; hostKeyType: string; verdict: SshTesterVerdict; } export interface SshTester { test(args: SshTesterArgs): Promise; } export interface SshEncryptResult { blob: Buffer; passphraseBlob: Buffer | null; keyVersion: number; fingerprint: string; publicKey: string; } export interface SshApiDeps { db: Database.Database; requireAuth: RequestHandler; requireAdmin: RequestHandler; getUserId(req: Request): string | null; isAdmin(req: Request): boolean; getOrgIds(req: Request): string[]; /** * Whether the bridge wired the auth subsystem. When `false` (no-auth * single-user deployment) requests carry no `req.user`, so the router * synthesizes a stable `local` owner — otherwise every endpoint 401s on * the null userId and SSH is unusable. Mirrors notes-api / memory-api / * user-folder-api. Defaults to `true` for existing (auth-only) callers. */ authActive?: boolean; connectionRepo: SshConnectionRepo; grantsRepo: SshGrantsRepo; auditRepo: SshAuditRepo; abuseRepo: SshAbuseRepo; accessResolver: SshAccessResolver; maintenance: MaintenanceController; forceUnlockLimiter: AdminRateLimiter; /** * Encrypt a PEM (and optional passphrase) into the envelope-encrypted blobs * stored in ssh_connections. ownerId=null = system DEK (global connection), * ownerId= = user DEK. */ encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null): SshEncryptResult; /** Decrypt blob using the correct DEK. Buffer must be cleared by caller. */ decryptKeyMaterial(ownerId: string | null, blob: Buffer): Buffer; decryptPassphrase(ownerId: string | null, blob: Buffer | null): Buffer | null; /** * Generate a fresh keypair (no passphrase). The caller is expected to feed * the returned `privateKeyPem` back through `encryptKeyMaterial` for at-rest * encryption. */ generateKeypair(keyType: 'ed25519' | 'rsa-4096'): { privateKeyPem: Buffer; publicKey: string; }; /** * Derive the OpenSSH-format public key (` `) from the stored * private-key blob. Handles decrypt + format + buffer-clear internally. */ derivePublicKey( ownerId: string | null, blob: Buffer, passphraseBlob: Buffer | null, ): string; /** SSH dial helper — injected so tests don't need a real SSH server. */ sshTester: SshTester; /** Connection test timeout. Defaults to 5s. */ connectionTestTimeoutMs?: number; /** * Optional hook called after a grant is revoked / deleted so the caller can * kick any active WebSocket viewers that depended on that grant. The hook * is a no-op pass-through (returns 0) when the SSH console subsystem isn't * wired (e.g. tests, or `ssh.console.enabled=false`). * * Implementation typically calls `SessionRegistry.revokeAccessFor`. */ onAccessRevoked?: (args: { connectionId: string; userId: string }) => number | void; } // ────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────── const REASON_MIN_CHARS = 8; function validateReason(reason: unknown): string | null { if (typeof reason !== 'string') return 'reason is required (string >= 8 chars)'; if (reason.length < REASON_MIN_CHARS) return `reason must be at least ${REASON_MIN_CHARS} characters`; return null; } function maintenance503(maintenance: MaintenanceController, res: Response): boolean { if (!maintenance.isActive()) return false; res.setHeader('Retry-After', '30'); res.status(503).json({ error: 'rotation_in_progress', detail: 'SSH subsystem is in maintenance — try again shortly.', snapshot: maintenance.snapshot(), }); return true; } function safePort(p: unknown): number | null { if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) return null; return p; } function safeString(v: unknown, max = 1024): string | null { if (typeof v !== 'string') return null; if (v.length === 0 || v.length > max) return null; return v; } function safeOptionalString(v: unknown, max = 1024): string | null | undefined { if (v === undefined || v === null) return undefined; if (typeof v !== 'string') return null; if (v.length > max) return null; return v; } function safePathPrefix(v: unknown): string | null { const s = safeString(v, 1024); if (s === null) return null; if (s.length === 0) return null; // Reject any `..` parent-ref segment using either separator. POSIX and // Windows-style prefixes (drive letters, UNC, no-leading-slash) are // all allowed; the runtime validateRemotePath enforces containment. const segments = s.split(/[\\/]/); if (segments.includes('..')) return null; return s; } function safeFingerprint(v: unknown): string | null { if (typeof v !== 'string') return null; // SHA256: — base64 chars + ='s, ~46 chars typical. if (!/^SHA256:[A-Za-z0-9+/=]{20,80}$/.test(v)) return null; return v; } function safeUuid(v: unknown): string | null { if (typeof v !== 'string') return null; if (!/^[a-f0-9-]{8,}$/.test(v)) return null; return v; } function safeExpiresAt(v: unknown): string | null | undefined { if (v === undefined || v === null) return undefined; if (typeof v !== 'string') return null; // ISO8601 — Date.parse returns NaN for invalid strings. const t = Date.parse(v); if (Number.isNaN(t)) return null; return new Date(t).toISOString(); } function safeSubjectType(v: unknown): SshGrantSubjectType | null { return v === 'user' || v === 'org' ? v : null; } function presentConnection(conn: SshConnection): Record { // Strip the encrypted blob fields and the pending verify token (the token // is only surfaced on /test responses; subsequent GETs do not expose it // again — the user must hold onto it). return { id: conn.id, ownerId: conn.ownerId, label: conn.label, host: conn.host, port: conn.port, username: conn.username, keyVersion: conn.keyVersion, keyFingerprint: conn.keyFingerprint, hostKeyType: conn.hostKeyType, hostKeyFingerprint: conn.hostKeyFingerprint, hostKeyRecordedAt: conn.hostKeyRecordedAt, hostKeyVerifiedAt: conn.hostKeyVerifiedAt, hostKeyPending: conn.hostKeyPending, hostKeyPendingFingerprint: conn.hostKeyPendingFingerprint, hostKeyPendingSource: conn.hostKeyPendingSource, commandDenyPatterns: conn.commandDenyPatterns, commandAllowPatterns: conn.commandAllowPatterns, remotePathPrefix: conn.remotePathPrefix, allowRemoteUnrestricted: conn.allowRemoteUnrestricted, allowPrivateAddresses: conn.allowPrivateAddresses, enabled: conn.enabled, disabledByAdmin: conn.disabledByAdmin, disabledByAdminReason: conn.disabledByAdminReason, disabledByAdminAt: conn.disabledByAdminAt, disabledByAdminUserId: conn.disabledByAdminUserId, createdAt: conn.createdAt, updatedAt: conn.updatedAt, }; } function presentGrant(g: SshGrant): Record { return { ...g }; } function presentAuditRow(a: SshAuditRow): Record { return { ...a }; } function safeLimit(v: unknown, def = 50, max = 500): number { if (typeof v === 'string') { const n = Number(v); if (Number.isInteger(n) && n > 0 && n <= max) return n; } if (typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= max) return v; return def; } function jsonError(res: Response, status: number, error: string, detail?: unknown): void { if (detail === undefined) { res.status(status).json({ error }); } else { res.status(status).json({ error, detail }); } } // ────────────────────────────────────────────────────────────────────── // User router — /api/ssh/connections + /api/ssh/grants // ────────────────────────────────────────────────────────────────────── /** * No-auth single-user mode: synthesize a `local` user so the per-user * connection store has a stable owner and `getUserId`/`isAdmin` resolve. * Role `admin` keeps the connection-management surface fully usable for the * lone local operator (in single-user mode the admin/grant checks only ever * compare against `local`-owned rows, so this never widens cross-user access). * No-op when auth is active (Passport has already populated `req.user`). */ function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler { return (req: Request, _res: Response, next: NextFunction) => { if (!authActive && !(req as { user?: unknown }).user) { (req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] }; } next(); }; } export function createSshUserRouter(deps: SshApiDeps): Router { const router = Router(); const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000; router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); // GET /api/ssh/connections — list owned (and globals visible via grant). // For Phase 5 we return: // - all owned (owner_id == userId) // - all globals (owner_id IS NULL) — visibility is enforced at tool-call // time via accessResolver, so listing globals here is informational. // Admins see all via /api/ssh/admin/connections (separate endpoint). router.get('/connections', deps.requireAuth, (req, res) => { try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const owned = deps.connectionRepo.listOwned(userId); const all = deps.connectionRepo.listAll(); const globals = all.filter((c) => c.ownerId === null); // Deduplicate (owned should never overlap globals; safety net). const seen = new Set(owned.map((c) => c.id)); const list = [...owned]; for (const g of globals) { if (!seen.has(g.id)) { list.push(g); seen.add(g.id); } } res.json({ connections: list.map(presentConnection) }); } catch (e) { logger.warn(`[ssh:api] list user connections failed err=${String(e)}`); jsonError(res, 500, 'list_failed'); } }); // POST /api/ssh/connections — create user-owned. router.post('/connections', deps.requireAuth, async (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const body = req.body ?? {}; const label = safeString(body.label, 200); const host = safeString(body.host, 255); const port = safePort(body.port); const username = safeString(body.username, 64); const remotePathPrefix = safePathPrefix(body.remotePathPrefix); const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096); const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096); // User-owned create rejects admin-only flags up front. if (body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1) { jsonError(res, 403, 'allow_remote_unrestricted_admin_only'); return; } if (body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1) { jsonError(res, 403, 'allow_private_addresses_admin_only'); return; } // Keypair source: 'provided' (user uploads PEM, default) or // 'generate' (orchestrator creates a fresh keypair). In the latter case // the user gets the public key back exactly once in this response. const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided'; let pemBuf: Buffer; let passBuf: Buffer | null; if (keypairSource === 'generate') { const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519'; const generated = deps.generateKeypair(keyType); pemBuf = generated.privateKeyPem; passBuf = null; } else { const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null; const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null; if (!privateKey) { jsonError(res, 400, 'invalid_input', { required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'], }); return; } pemBuf = Buffer.from(privateKey, 'utf8'); passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null; } if (!label || !host || port === null || !username || !remotePathPrefix) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); jsonError(res, 400, 'invalid_input', { required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'], }); return; } if (denyPatterns === null) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); jsonError(res, 400, 'invalid_command_deny_patterns'); return; } if (allowPatterns === null) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); jsonError(res, 400, 'invalid_command_allow_patterns'); return; } let encrypted: SshEncryptResult; try { encrypted = deps.encryptKeyMaterial(userId, pemBuf, passBuf); } finally { pemBuf.fill(0); if (passBuf) passBuf.fill(0); } const conn = deps.connectionRepo.create({ ownerId: userId, label, host, port, username, privateKeyEnc: encrypted.blob, passphraseEnc: encrypted.passphraseBlob, keyVersion: encrypted.keyVersion, keyFingerprint: encrypted.fingerprint, remotePathPrefix, commandDenyPatterns: denyPatterns ?? null, commandAllowPatterns: allowPatterns ?? null, }); deps.auditRepo.beginAndComplete( { action: 'ssh.connection.upsert', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: userId, actingUserId: userId, detail: { op: 'create', label: conn.label, host: conn.host, port: conn.port, keypairSource }, }, 'success', ); res.status(201).json({ connection: presentConnection(conn), publicKey: encrypted.publicKey, }); } catch (e) { logger.warn(`[ssh:api] create user connection failed err=${String(e)}`); jsonError(res, 500, 'create_failed'); } }); router.get('/connections/:id', deps.requireAuth, (req, res) => { try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } // Visibility: owner, admin, or global (info-only). Other users see 404 // (do not leak existence of someone else's user-owned connection). if (conn.ownerId !== null && conn.ownerId !== userId && !deps.isAdmin(req)) { jsonError(res, 404, 'not_found'); return; } let publicKey: string | null = null; try { publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc); } catch (e) { // Don't fail the whole response if key derivation fails (e.g. corrupt // blob, missing passphrase) — return the connection without it and log. logger.warn(`[ssh:api] derive public key failed id=${conn.id} err=${String(e)}`); } res.json({ connection: presentConnection(conn), publicKey }); } catch (e) { logger.warn(`[ssh:api] get user connection failed err=${String(e)}`); jsonError(res, 500, 'get_failed'); } }); router.patch('/connections/:id', deps.requireAuth, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } // Only owner may PATCH user-owned. Admin PATCHes globals via admin route. if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; } const body = req.body ?? {}; const patch: Parameters[1] = {}; if (body.label !== undefined) { const v = safeString(body.label, 200); if (v === null) { jsonError(res, 400, 'invalid_label'); return; } patch.label = v; } if (body.host !== undefined) { const v = safeString(body.host, 255); if (v === null) { jsonError(res, 400, 'invalid_host'); return; } patch.host = v; } if (body.port !== undefined) { const v = safePort(body.port); if (v === null) { jsonError(res, 400, 'invalid_port'); return; } patch.port = v; } if (body.username !== undefined) { const v = safeString(body.username, 64); if (v === null) { jsonError(res, 400, 'invalid_username'); return; } patch.username = v; } if (body.remotePathPrefix !== undefined) { const v = safePathPrefix(body.remotePathPrefix); if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; } patch.remotePathPrefix = v; } if (body.commandDenyPatterns !== undefined) { const v = safeOptionalString(body.commandDenyPatterns, 4096); if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; } patch.commandDenyPatterns = v ?? null; } if (body.commandAllowPatterns !== undefined) { const v = safeOptionalString(body.commandAllowPatterns, 4096); if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; } patch.commandAllowPatterns = v ?? null; } // Users cannot toggle admin-only flags. if (body.allowRemoteUnrestricted !== undefined || body.allowPrivateAddresses !== undefined) { jsonError(res, 403, 'admin_only_flag'); return; } // Key rotation (privateKeyPem in patch). Re-encrypt and bump key_version. if (typeof body.privateKeyPem === 'string') { let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; try { const enc = deps.encryptKeyMaterial(userId, pemBuf, passBuf); patch.privateKeyEnc = enc.blob; patch.passphraseEnc = enc.passphraseBlob; patch.keyVersion = enc.keyVersion; patch.keyFingerprint = enc.fingerprint; } finally { pemBuf.fill(0); if (passBuf) passBuf.fill(0); } } const ok = deps.connectionRepo.update(conn.id, patch); if (!ok) { jsonError(res, 404, 'not_found'); return; } const updated = deps.connectionRepo.resolveConnection(conn.id); deps.auditRepo.beginAndComplete( { action: 'ssh.connection.upsert', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: userId, actingUserId: userId, detail: { op: 'update', fields: Object.keys(patch) }, }, 'success', ); res.json({ connection: presentConnection(updated!) }); } catch (e) { logger.warn(`[ssh:api] patch user connection failed err=${String(e)}`); jsonError(res, 500, 'update_failed'); } }); router.delete('/connections/:id', deps.requireAuth, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; } // Begin audit BEFORE delete so FK(connection_id) is still valid. // FK has ON DELETE SET NULL; complete() updates by audit_id only. const auditId = deps.auditRepo.begin({ action: 'ssh.connection.delete', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: userId, actingUserId: userId, detail: { label: conn.label, host: conn.host }, }); const ok = deps.connectionRepo.delete(conn.id); if (!ok) { deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); jsonError(res, 404, 'not_found'); return; } deps.auditRepo.complete(auditId, 'success'); res.json({ ok: true }); } catch (e) { logger.warn(`[ssh:api] delete user connection failed err=${String(e)}`); jsonError(res, 500, 'delete_failed'); } }); // POST /api/ssh/connections/:id/test — capture-only connect. Decrypts the // key, dials the host (or asks the injected tester to do so), captures the // host key, and stores it pending with a fresh verify token. Returns the // fingerprint + token to the user so they can call /verify-host-key next. router.post('/connections/:id/test', deps.requireAuth, async (req, res) => { if (maintenance503(deps.maintenance, res)) return; const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== userId && !deps.isAdmin(req)) { jsonError(res, 403, 'owner_or_admin_only'); return; } let decryptedKey: Buffer | null = null; let passphrase: Buffer | null = null; let auditId: number | null = null; try { auditId = deps.auditRepo.begin({ action: 'ssh.connection.host_key.tofu_record', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, detail: { op: 'test' }, }); decryptedKey = deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc); passphrase = deps.decryptPassphrase(conn.ownerId, conn.passphraseEnc); const result = await deps.sshTester.test({ connection: conn, decryptedKey, passphrase, timeoutMs: testTimeoutMs, }); // alg_not_allowed: the observed host key uses a banned algorithm // (e.g. ssh-rsa with SHA1). Don't store as pending — we'd never accept it. // Surface the fingerprint so the operator can audit/replace the server's key. if (result.verdict === 'alg_not_allowed') { deps.auditRepo.complete(auditId, 'denied', { verdict: result.verdict, observedFingerprint: result.fingerprint, hostKeyType: result.hostKeyType, }); res.status(502).json({ error: 'host_key_alg_not_allowed', verdict: result.verdict, fingerprint: result.fingerprint, hostKeyType: result.hostKeyType, }); return; } // Store the observation as pending for first_observe and mismatch. // 'pass' means the key already matched the verified record — no token needed. let token: string | null = null; if (result.verdict === 'first_observe' || result.verdict === 'mismatch') { const stored = deps.connectionRepo.setHostKeyPendingWithToken( conn.id, result.hostKeyB64, result.fingerprint, result.verdict === 'first_observe' ? 'tofu_record' : 'mismatch', ); token = stored?.token ?? null; } deps.auditRepo.complete(auditId, 'success', { verdict: result.verdict, observedFingerprint: result.fingerprint, }); res.json({ verdict: result.verdict, fingerprint: result.fingerprint, hostKeyType: result.hostKeyType, pendingToken: token, }); } catch (e) { if (auditId !== null) { deps.auditRepo.complete(auditId, 'failed', { err: String(e) }); } logger.warn(`[ssh:api] connection test failed id=${conn.id} err=${String(e)}`); jsonError(res, 502, 'test_failed', { detail: String(e) }); } finally { if (decryptedKey) decryptedKey.fill(0); if (passphrase) passphrase.fill(0); } }); // POST /api/ssh/connections/:id/verify-host-key router.post('/connections/:id/verify-host-key', deps.requireAuth, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== userId && !deps.isAdmin(req)) { jsonError(res, 403, 'owner_or_admin_only'); return; } const body = req.body ?? {}; const fingerprint = safeFingerprint(body.fingerprint); const token = safeUuid(body.token); if (!fingerprint || !token) { jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token'] }); return; } const result: HostKeyVerifyResult = deps.connectionRepo.setHostKeyVerified(conn.id, token, fingerprint); deps.auditRepo.beginAndComplete( { action: 'ssh.connection.host_key.verify', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, detail: { result, fingerprint }, }, result === 'verified' ? 'success' : 'failed', ); if (result === 'verified') { const updated = deps.connectionRepo.resolveConnection(conn.id)!; res.json({ ok: true, connection: presentConnection(updated) }); } else { jsonError(res, 409, result); } } catch (e) { logger.warn(`[ssh:api] verify host key failed err=${String(e)}`); jsonError(res, 500, 'verify_failed'); } }); // POST /api/ssh/connections/:id/replace-host-key — rotate an already-verified key. router.post('/connections/:id/replace-host-key', deps.requireAuth, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== userId && !deps.isAdmin(req)) { jsonError(res, 403, 'owner_or_admin_only'); return; } const body = req.body ?? {}; const fingerprint = safeFingerprint(body.fingerprint); const token = safeUuid(body.token); const reasonErr = validateReason(body.reason); if (!fingerprint || !token) { jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token', 'reason'] }); return; } if (reasonErr) { jsonError(res, 400, reasonErr); return; } const result: HostKeyVerifyResult = deps.connectionRepo.replaceHostKey(conn.id, token, fingerprint); deps.auditRepo.beginAndComplete( { action: 'ssh.connection.host_key.replace', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, reason: String(body.reason), detail: { result, fingerprint }, }, result === 'verified' ? 'success' : 'failed', ); if (result === 'verified') { const updated = deps.connectionRepo.resolveConnection(conn.id)!; res.json({ ok: true, connection: presentConnection(updated) }); } else { jsonError(res, 409, result); } } catch (e) { logger.warn(`[ssh:api] replace host key failed err=${String(e)}`); jsonError(res, 500, 'replace_failed'); } }); router.get('/connections/:id/audit', deps.requireAuth, (req, res) => { try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== userId && !deps.isAdmin(req)) { jsonError(res, 403, 'owner_or_admin_only'); return; } const limit = safeLimit(req.query.limit); const rows = deps.auditRepo.listForConnection(conn.id, limit); res.json({ audit: rows.map(presentAuditRow) }); } catch (e) { logger.warn(`[ssh:api] audit list failed err=${String(e)}`); jsonError(res, 500, 'audit_failed'); } }); // GET /api/ssh/grants/visible-to-me router.get('/grants/visible-to-me', deps.requireAuth, (req, res) => { try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const orgIds = deps.getOrgIds(req); const orgPh = orgIds.length > 0 ? orgIds.map(() => '?').join(',') : null; const sql = ` SELECT * FROM ssh_connection_grants WHERE (subject_type = 'user' AND subject_id = ?) ${orgPh ? `OR (subject_type = 'org' AND subject_id IN (${orgPh}))` : ''} ORDER BY created_at DESC `; const params: unknown[] = [userId]; if (orgPh) params.push(...orgIds); const rows = deps.db.prepare(sql).all(...params) as Array<{ id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string; piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string; reason: string; expires_at: string | null; created_at: string; }>; const grants = rows.map((r) => ({ id: r.id, connectionId: r.connection_id, subjectType: r.subject_type, subjectId: r.subject_id, pieceName: r.piece_name, appliesToAllPieces: r.applies_to_all_pieces === 1, grantedByUserId: r.granted_by_user_id, reason: r.reason, expiresAt: r.expires_at, createdAt: r.created_at, })); res.json({ grants }); } catch (e) { logger.warn(`[ssh:api] list visible grants failed err=${String(e)}`); jsonError(res, 500, 'list_grants_failed'); } }); return router; } // ────────────────────────────────────────────────────────────────────── // Admin router — /api/ssh/admin/* // ────────────────────────────────────────────────────────────────────── export function createSshAdminRouter(deps: SshApiDeps): Router { const router = Router(); router.use(makeSshLocalUserMiddleware(deps.authActive ?? true)); // GET /api/ssh/admin/connections router.get('/connections', deps.requireAdmin, (_req, res) => { try { const list = deps.connectionRepo.listAll(); res.json({ connections: list.map(presentConnection) }); } catch (e) { logger.warn(`[ssh:api] admin list connections failed err=${String(e)}`); jsonError(res, 500, 'list_failed'); } }); router.get('/connections/:id', deps.requireAdmin, (req, res) => { try { const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } let publicKey: string | null = null; try { publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc); } catch (e) { logger.warn(`[ssh:api] admin derive public key failed id=${conn.id} err=${String(e)}`); } res.json({ connection: presentConnection(conn), publicKey }); } catch (e) { logger.warn(`[ssh:api] admin get connection failed err=${String(e)}`); jsonError(res, 500, 'get_failed'); } }); router.patch('/connections/:id/disable', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } const ok = deps.connectionRepo.disableByAdmin(conn.id, String(req.body.reason), userId); if (!ok) { jsonError(res, 404, 'not_found'); return; } deps.auditRepo.beginAndComplete( { action: 'ssh.connection.disable', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, reason: String(req.body.reason), }, 'success', ); const updated = deps.connectionRepo.resolveConnection(conn.id)!; res.json({ connection: presentConnection(updated) }); } catch (e) { logger.warn(`[ssh:api] admin disable failed err=${String(e)}`); jsonError(res, 500, 'disable_failed'); } }); router.patch('/connections/:id/enable', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } const ok = deps.connectionRepo.enableByAdmin(conn.id); if (!ok) { jsonError(res, 404, 'not_found'); return; } deps.auditRepo.beginAndComplete( { action: 'ssh.connection.enable', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, reason: String(req.body.reason), }, 'success', ); const updated = deps.connectionRepo.resolveConnection(conn.id)!; res.json({ connection: presentConnection(updated) }); } catch (e) { logger.warn(`[ssh:api] admin enable failed err=${String(e)}`); jsonError(res, 500, 'enable_failed'); } }); router.delete('/connections/:id', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } const auditId = deps.auditRepo.begin({ action: 'ssh.connection.delete', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, reason: String(req.body.reason), }); const ok = deps.connectionRepo.delete(conn.id); if (!ok) { deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); jsonError(res, 404, 'not_found'); return; } deps.auditRepo.complete(auditId, 'success'); res.json({ ok: true }); } catch (e) { logger.warn(`[ssh:api] admin delete failed err=${String(e)}`); jsonError(res, 500, 'delete_failed'); } }); router.post('/connections/:id/force-unlock', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const decision = deps.forceUnlockLimiter.check(userId); if (!decision.allowed) { if (decision.retryAfterSeconds !== undefined) { res.setHeader('Retry-After', String(decision.retryAfterSeconds)); } jsonError(res, 429, 'rate_limited', { retryAfterSeconds: decision.retryAfterSeconds }); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } const removed = deps.abuseRepo.reset(`conn:${conn.id}`); deps.auditRepo.beginAndComplete( { action: 'ssh.abuse.unlock_manual', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, reason: String(req.body.reason), detail: { removed }, }, 'success', ); res.json({ ok: true, removed }); } catch (e) { logger.warn(`[ssh:api] admin force-unlock failed err=${String(e)}`); jsonError(res, 500, 'unlock_failed'); } }); // POST /api/ssh/admin/globals — create a global connection (owner_id=NULL). router.post('/globals', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const body = req.body ?? {}; const label = safeString(body.label, 200); const host = safeString(body.host, 255); const port = safePort(body.port); const username = safeString(body.username, 64); const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096); const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096); const allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1; const allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1; // remote_path_prefix is required UNLESS allow_remote_unrestricted is set. let remotePathPrefix: string | null; if (allowRemoteUnrestricted) { // Stored prefix is '/' (sandbox-effectively-disabled). remotePathPrefix = '/'; } else { remotePathPrefix = safePathPrefix(body.remotePathPrefix); } // Keypair source: 'provided' (admin uploads PEM) or 'generate' (orchestrator // creates a fresh keypair; the public key is returned exactly once). const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided'; let pemBuf: Buffer; let passBuf: Buffer | null; if (keypairSource === 'generate') { const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519'; const generated = deps.generateKeypair(keyType); pemBuf = generated.privateKeyPem; passBuf = null; } else { const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null; const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null; if (!privateKey) { jsonError(res, 400, 'invalid_input', { required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'], }); return; } pemBuf = Buffer.from(privateKey, 'utf8'); passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null; } if (!label || !host || port === null || !username || !remotePathPrefix) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); jsonError(res, 400, 'invalid_input', { required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'], }); return; } if (denyPatterns === null) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); jsonError(res, 400, 'invalid_command_deny_patterns'); return; } if (allowPatterns === null) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); jsonError(res, 400, 'invalid_command_allow_patterns'); return; } let encrypted: SshEncryptResult; try { encrypted = deps.encryptKeyMaterial(null, pemBuf, passBuf); } catch (e) { pemBuf.fill(0); if (passBuf) passBuf.fill(0); logger.warn(`[ssh:api] admin global encrypt failed err=${String(e)}`); jsonError(res, 500, 'encrypt_failed'); return; } pemBuf.fill(0); if (passBuf) passBuf.fill(0); try { const conn = deps.connectionRepo.create({ ownerId: null, label, host, port, username, privateKeyEnc: encrypted.blob, passphraseEnc: encrypted.passphraseBlob, keyVersion: encrypted.keyVersion, keyFingerprint: encrypted.fingerprint, remotePathPrefix, allowRemoteUnrestricted, allowPrivateAddresses, commandDenyPatterns: denyPatterns ?? null, commandAllowPatterns: allowPatterns ?? null, }); deps.auditRepo.beginAndComplete( { action: 'ssh.connection.upsert', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: null, actingUserId: userId, reason: String(body.reason), detail: { op: 'create', scope: 'global', allowRemoteUnrestricted, allowPrivateAddresses, keypairSource }, }, 'success', ); res.status(201).json({ connection: presentConnection(conn), publicKey: encrypted.publicKey, }); } catch (e) { logger.warn(`[ssh:api] admin global create failed err=${String(e)}`); jsonError(res, 500, 'create_failed'); } }); router.patch('/globals/:id', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; } const body = req.body ?? {}; const patch: Parameters[1] = {}; if (body.label !== undefined) { const v = safeString(body.label, 200); if (v === null) { jsonError(res, 400, 'invalid_label'); return; } patch.label = v; } if (body.host !== undefined) { const v = safeString(body.host, 255); if (v === null) { jsonError(res, 400, 'invalid_host'); return; } patch.host = v; } if (body.port !== undefined) { const v = safePort(body.port); if (v === null) { jsonError(res, 400, 'invalid_port'); return; } patch.port = v; } if (body.username !== undefined) { const v = safeString(body.username, 64); if (v === null) { jsonError(res, 400, 'invalid_username'); return; } patch.username = v; } if (body.remotePathPrefix !== undefined) { const v = safePathPrefix(body.remotePathPrefix); if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; } patch.remotePathPrefix = v; } if (body.commandDenyPatterns !== undefined) { const v = safeOptionalString(body.commandDenyPatterns, 4096); if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; } patch.commandDenyPatterns = v ?? null; } if (body.commandAllowPatterns !== undefined) { const v = safeOptionalString(body.commandAllowPatterns, 4096); if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; } patch.commandAllowPatterns = v ?? null; } if (body.allowRemoteUnrestricted !== undefined) { patch.allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1; } if (body.allowPrivateAddresses !== undefined) { patch.allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1; } if (typeof body.privateKeyPem === 'string') { let pemBuf = Buffer.from(body.privateKeyPem, 'utf8'); let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null; try { const enc = deps.encryptKeyMaterial(null, pemBuf, passBuf); patch.privateKeyEnc = enc.blob; patch.passphraseEnc = enc.passphraseBlob; patch.keyVersion = enc.keyVersion; patch.keyFingerprint = enc.fingerprint; } finally { pemBuf.fill(0); if (passBuf) passBuf.fill(0); } } const ok = deps.connectionRepo.update(conn.id, patch); if (!ok) { jsonError(res, 404, 'not_found'); return; } deps.auditRepo.beginAndComplete( { action: 'ssh.connection.upsert', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: null, actingUserId: userId, reason: String(req.body.reason), detail: { op: 'update', scope: 'global', fields: Object.keys(patch) }, }, 'success', ); const updated = deps.connectionRepo.resolveConnection(conn.id)!; res.json({ connection: presentConnection(updated) }); } catch (e) { logger.warn(`[ssh:api] admin global patch failed err=${String(e)}`); jsonError(res, 500, 'update_failed'); } }); router.delete('/globals/:id', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const conn = deps.connectionRepo.resolveConnection(req.params.id); if (!conn) { jsonError(res, 404, 'not_found'); return; } if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; } const auditId = deps.auditRepo.begin({ action: 'ssh.connection.delete', entityType: 'ssh_connection', entityId: conn.id, connectionId: conn.id, ownerId: null, actingUserId: userId, reason: String(req.body.reason), }); const ok = deps.connectionRepo.delete(conn.id); if (!ok) { deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' }); jsonError(res, 404, 'not_found'); return; } deps.auditRepo.complete(auditId, 'success'); res.json({ ok: true }); } catch (e) { logger.warn(`[ssh:api] admin global delete failed err=${String(e)}`); jsonError(res, 500, 'delete_failed'); } }); router.get('/grants', deps.requireAdmin, (req, res) => { try { const limit = safeLimit(req.query.limit, 100, 1000); const rows = deps.db.prepare( `SELECT * FROM ssh_connection_grants ORDER BY created_at DESC LIMIT ?`, ).all(limit) as Array<{ id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string; piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string; reason: string; expires_at: string | null; created_at: string; }>; const grants = rows.map((r) => ({ id: r.id, connectionId: r.connection_id, subjectType: r.subject_type, subjectId: r.subject_id, pieceName: r.piece_name, appliesToAllPieces: r.applies_to_all_pieces === 1, grantedByUserId: r.granted_by_user_id, reason: r.reason, expiresAt: r.expires_at, createdAt: r.created_at, })); res.json({ grants }); } catch (e) { logger.warn(`[ssh:api] admin list grants failed err=${String(e)}`); jsonError(res, 500, 'list_grants_failed'); } }); router.post('/grants', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const body = req.body ?? {}; const reasonErr = validateReason(body.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const connectionId = safeUuid(body.connectionId); const subjectType = safeSubjectType(body.subjectType); const subjectId = safeString(body.subjectId, 128); const appliesToAllPieces = body.appliesToAllPieces === true || body.appliesToAllPieces === 1; const pieceName = safeOptionalString(body.pieceName, 64); const expiresAt = safeExpiresAt(body.expiresAt); if (!connectionId || !subjectType || !subjectId) { jsonError(res, 400, 'invalid_input', { required: ['connectionId', 'subjectType ("user"|"org")', 'subjectId', 'reason'], }); return; } if (pieceName === null) { jsonError(res, 400, 'invalid_piece_name'); return; } if (expiresAt === null) { jsonError(res, 400, 'invalid_expires_at'); return; } if (!appliesToAllPieces && (!pieceName || pieceName.length === 0)) { jsonError(res, 400, 'piece_name_required_unless_applies_to_all'); return; } if (appliesToAllPieces && pieceName !== undefined && pieceName.length > 0) { jsonError(res, 400, 'piece_name_conflicts_with_applies_to_all'); return; } // Connection must exist before granting. const conn = deps.connectionRepo.resolveConnection(connectionId); if (!conn) { jsonError(res, 404, 'connection_not_found'); return; } let grant: SshGrant; try { grant = deps.grantsRepo.create({ connectionId, subjectType, subjectId, pieceName: appliesToAllPieces ? null : (pieceName ?? null), appliesToAllPieces, grantedByUserId: userId, reason: String(body.reason), expiresAt, }); } catch (e) { jsonError(res, 400, 'grant_create_failed', { detail: String(e) }); return; } deps.auditRepo.beginAndComplete( { action: 'ssh.grant.create', entityType: 'ssh_grant', entityId: grant.id, connectionId: conn.id, ownerId: conn.ownerId, actingUserId: userId, reason: String(body.reason), detail: { subjectType, subjectId, pieceName: grant.pieceName, appliesToAllPieces }, }, 'success', ); res.status(201).json({ grant: presentGrant(grant) }); } catch (e) { logger.warn(`[ssh:api] admin grant create failed err=${String(e)}`); jsonError(res, 500, 'grant_create_failed'); } }); router.delete('/grants/:id', deps.requireAdmin, (req, res) => { if (maintenance503(deps.maintenance, res)) return; try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } const grant = deps.grantsRepo.getById(req.params.id); if (!grant) { jsonError(res, 404, 'not_found'); return; } const ok = deps.grantsRepo.delete(grant.id); if (!ok) { jsonError(res, 404, 'not_found'); return; } // Kick any active console-WS viewers whose access depended on this grant. // user-subject grants map 1:1 to a single userId; org-subject grants are // deferred (next attach will be denied; existing viewers stay until the // session ends or the org-member's WS reconnects). let kicked: number | void = 0; if (grant.subjectType === 'user' && deps.onAccessRevoked) { kicked = deps.onAccessRevoked({ connectionId: grant.connectionId, userId: grant.subjectId }); } deps.auditRepo.beginAndComplete( { action: 'ssh.grant.delete', entityType: 'ssh_grant', entityId: grant.id, connectionId: grant.connectionId, actingUserId: userId, reason: String(req.body.reason), detail: { subjectType: grant.subjectType, subjectId: grant.subjectId, pieceName: grant.pieceName, viewersKicked: typeof kicked === 'number' ? kicked : 0, }, }, 'success', ); res.json({ ok: true }); } catch (e) { logger.warn(`[ssh:api] admin grant delete failed err=${String(e)}`); jsonError(res, 500, 'grant_delete_failed'); } }); // POST /api/ssh/admin/rotate-master-key — stub for v1 (Phase 5). // Sets the maintenance flag and audits the intent. The actual DEK re-wrap // job is deferred to a follow-up PR per Phase 5 design note (line 728). // Returns 501 in the response body but 202 status so the UI can show a // banner; UI Phase 6 will key off the maintenance snapshot. router.post('/rotate-master-key', deps.requireAdmin, (req, res) => { try { const userId = deps.getUserId(req); if (!userId) { jsonError(res, 401, 'unauthorized'); return; } const reasonErr = validateReason(req.body?.reason); if (reasonErr) { jsonError(res, 400, reasonErr); return; } if (deps.maintenance.isActive()) { jsonError(res, 409, 'already_in_progress', { snapshot: deps.maintenance.snapshot() }); return; } const jobId = `rotate-${Date.now().toString(36)}`; deps.maintenance.enter(String(req.body.reason), jobId); deps.auditRepo.beginAndComplete( { action: 'ssh.master_key.rotate.start', actingUserId: userId, reason: String(req.body.reason), detail: { jobId, status: 'stub', note: 'rotation job not implemented in v1; only maintenance flag set' }, }, 'success', ); res.status(202).json({ jobId, status: 'maintenance_set', detail: 'maintenance flag is now active; actual DEK re-wrap is not implemented in v1', notImplemented: true, }); } catch (e) { logger.warn(`[ssh:api] admin rotate-master-key failed err=${String(e)}`); jsonError(res, 500, 'rotate_failed'); } }); router.get('/rotate-master-key/:jobId', deps.requireAdmin, (req, res) => { try { const snap = deps.maintenance.snapshot(); if (!snap.active || snap.jobId !== req.params.jobId) { jsonError(res, 404, 'not_found_or_completed', { snapshot: snap }); return; } res.json({ jobId: snap.jobId, status: 'in_progress', startedAt: snap.enteredAt, progress: { note: 'v1 stub: no rewrap performed' }, notImplemented: true, }); } catch (e) { logger.warn(`[ssh:api] admin rotate-master-key get failed err=${String(e)}`); jsonError(res, 500, 'rotate_get_failed'); } }); // GET /api/ssh/admin/audit — cross-user audit query. router.get('/audit', deps.requireAdmin, (req, res) => { try { const limit = safeLimit(req.query.limit, 100, 1000); const action = typeof req.query.action === 'string' ? req.query.action : null; const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : null; const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : null; const outcome = typeof req.query.outcome === 'string' ? req.query.outcome : null; const where: string[] = []; const params: unknown[] = []; if (action) { where.push('action = ?'); params.push(action); } if (ownerId) { where.push('owner_id = ?'); params.push(ownerId); } if (connectionId) { where.push('connection_id = ?'); params.push(connectionId); } if (outcome) { where.push('outcome = ?'); params.push(outcome); } const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; const sql = `SELECT * FROM ssh_audit_log ${whereClause} ORDER BY started_at DESC LIMIT ?`; params.push(limit); const rows = deps.db.prepare(sql).all(...params) as Array<{ id: number; action: string; entity_type: string | null; entity_id: string | null; connection_id: string | null; owner_id: string | null; acting_user_id: string | null; job_id: string | null; piece_name: string | null; outcome: string; reason: string | null; detail: string | null; started_at: string; completed_at: string | null; }>; const audit = rows.map((r) => ({ id: r.id, action: r.action, entityType: r.entity_type, entityId: r.entity_id, connectionId: r.connection_id, ownerId: r.owner_id, actingUserId: r.acting_user_id, jobId: r.job_id, pieceName: r.piece_name, outcome: r.outcome, reason: r.reason, detail: r.detail ? JSON.parse(r.detail) : null, startedAt: r.started_at, completedAt: r.completed_at, })); res.json({ audit }); } catch (e) { logger.warn(`[ssh:api] admin audit list failed err=${String(e)}`); jsonError(res, 500, 'audit_failed'); } }); return router; }