From 1c1862062987c5d72726ebe1380bff1f33becdb9 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 13 Jul 2026 22:59:36 +0800 Subject: [PATCH] Add API key host enrollment endpoint (#1029) --- .../routes/host-enrollment-auth.test.ts | 120 ++++++++++++++++++ .../database/routes/host-enrollment-auth.ts | 46 +++++++ src/backend/database/routes/host.ts | 72 ++++++++++- src/backend/utils/auth-manager.ts | 2 + src/types/index.ts | 1 + 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/backend/database/routes/host-enrollment-auth.test.ts create mode 100644 src/backend/database/routes/host-enrollment-auth.ts diff --git a/src/backend/database/routes/host-enrollment-auth.test.ts b/src/backend/database/routes/host-enrollment-auth.test.ts new file mode 100644 index 00000000..97c4d2c8 --- /dev/null +++ b/src/backend/database/routes/host-enrollment-auth.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + isUserDataUnlocked: vi.fn(), +})); + +vi.mock("../../utils/simple-db-ops.js", () => ({ + SimpleDBOps: { + isUserDataUnlocked: mocks.isUserDataUnlocked, + }, +})); + +const { applyHostEnrollmentDefaults, requireHostEnrollmentAccessForPath } = + await import("./host-enrollment-auth.js"); + +function response() { + const json = vi.fn(); + const status = vi.fn(() => ({ json })); + return { status, json }; +} + +describe("requireHostEnrollmentAccessForPath", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.isUserDataUnlocked.mockReturnValue(true); + }); + + it("accepts an API key scoped to an unlocked user", () => { + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never, + res as never, + next, + ); + + expect(mocks.isUserDataUnlocked).toHaveBeenCalledWith("user-1"); + expect(next).toHaveBeenCalledOnce(); + }); + + it("rejects regular JWT sessions", () => { + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/enroll", userId: "user-1" } as never, + res as never, + next, + ); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: "Host enrollment requires an API key", + code: "API_KEY_REQUIRED", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("reports locked encrypted data explicitly", () => { + mocks.isUserDataUnlocked.mockReturnValue(false); + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/enroll", userId: "user-1", apiKeyId: "key-1" } as never, + res as never, + next, + ); + + expect(res.status).toHaveBeenCalledWith(423); + expect(res.json).toHaveBeenCalledWith({ + error: "User data is locked. Sign in before enrolling hosts.", + code: "DATA_LOCKED", + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("leaves the existing host route unchanged", () => { + const res = response(); + const next = vi.fn(); + + requireHostEnrollmentAccessForPath( + { path: "/db/host", userId: "user-1" } as never, + res as never, + next, + ); + + expect(next).toHaveBeenCalledOnce(); + expect(mocks.isUserDataUnlocked).not.toHaveBeenCalled(); + }); +}); + +describe("applyHostEnrollmentDefaults", () => { + it("creates a usable SSH host from a minimal enrollment payload", () => { + expect(applyHostEnrollmentDefaults({ ip: "server.example" })).toEqual({ + connectionType: "ssh", + ip: "server.example", + port: 22, + authType: "none", + enableTerminal: true, + enableSsh: true, + }); + }); + + it("preserves explicit enrollment settings", () => { + expect( + applyHostEnrollmentDefaults({ + ip: "server.example", + port: 2222, + authType: "password", + enableTerminal: false, + }), + ).toMatchObject({ + port: 2222, + authType: "password", + enableTerminal: false, + }); + }); +}); diff --git a/src/backend/database/routes/host-enrollment-auth.ts b/src/backend/database/routes/host-enrollment-auth.ts new file mode 100644 index 00000000..f5281c4f --- /dev/null +++ b/src/backend/database/routes/host-enrollment-auth.ts @@ -0,0 +1,46 @@ +import type { NextFunction, Request, Response } from "express"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import { SimpleDBOps } from "../../utils/simple-db-ops.js"; + +export function applyHostEnrollmentDefaults( + hostData: Record, +): Record { + return { + connectionType: "ssh", + port: 22, + authType: "none", + enableTerminal: true, + enableSsh: true, + ...hostData, + }; +} + +export function requireHostEnrollmentAccessForPath( + req: Request, + res: Response, + next: NextFunction, +): void { + if (req.path !== "/enroll") { + next(); + return; + } + + const authReq = req as AuthenticatedRequest; + if (!authReq.apiKeyId) { + res.status(401).json({ + error: "Host enrollment requires an API key", + code: "API_KEY_REQUIRED", + }); + return; + } + + if (!SimpleDBOps.isUserDataUnlocked(authReq.userId)) { + res.status(423).json({ + error: "User data is locked. Sign in before enrolling hosts.", + code: "DATA_LOCKED", + }); + return; + } + + next(); +} diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 9543e0b4..b71b3ee6 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -43,6 +43,10 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js"; import { registerHostInternalRoutes } from "./host-internal-routes.js"; import { registerHostNetworkRoutes } from "./host-network-routes.js"; import { registerHostBulkRoutes } from "./host-bulk-routes.js"; +import { + applyHostEnrollmentDefaults, + requireHostEnrollmentAccessForPath, +} from "./host-enrollment-auth.js"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; const router = express.Router(); @@ -164,9 +168,10 @@ registerHostInternalRoutes(router); * description: Failed to save SSH data. */ router.post( - "/db/host", + ["/db/host", "/enroll"], authenticateJWT, requireDataAccess, + requireHostEnrollmentAccessForPath, upload.single("key"), async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -199,6 +204,10 @@ router.post( hostData = req.body; } + if (req.path === "/enroll") { + hostData = applyHostEnrollmentDefaults(hostData); + } + const { connectionType, name, @@ -542,6 +551,67 @@ router.post( }, ); +/** + * @openapi + * /host/enroll: + * post: + * summary: Enroll a host with an API key + * description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in. + * tags: + * - Host Enrollment + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [ip] + * properties: + * name: + * type: string + * ip: + * type: string + * port: + * type: integer + * minimum: 1 + * maximum: 65535 + * default: 22 + * username: + * type: string + * authType: + * type: string + * enum: [none, password, key, credential, agent] + * default: none + * password: + * type: string + * folder: + * type: string + * tags: + * oneOf: + * - type: string + * - type: array + * items: + * type: string + * enableTerminal: + * type: boolean + * enableFileManager: + * type: boolean + * enableTunnel: + * type: boolean + * responses: + * 200: + * description: Host enrolled successfully. + * 400: + * description: Invalid host data. + * 401: + * description: Missing or invalid API key. + * 423: + * description: The API key user's encrypted data is locked. + * 500: + * description: Failed to enroll the host. + */ /** * @openapi * /host/quick-connect: diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 0d5f291d..9c73a7f8 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -42,6 +42,7 @@ interface WrappedDataKey { interface AuthenticatedRequest extends Request { userId?: string; sessionId?: string; + apiKeyId?: string; pendingTOTP?: boolean; dataKey?: Buffer; } @@ -881,6 +882,7 @@ class AuthManager { }); req.userId = matchedKey.userId; + req.apiKeyId = matchedKey.id; next(); } catch (error) { databaseLogger.error("API key authentication failed", error, { diff --git a/src/types/index.ts b/src/types/index.ts index fd7c2007..2c49b276 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -955,6 +955,7 @@ export type PartialExcept = Partial & Pick; export interface AuthenticatedRequest extends Request { userId: string; sessionId?: string; + apiKeyId?: string; user?: { id: string; username: string;