Add API key host enrollment endpoint (#1029)

This commit is contained in:
ZacharyZcR
2026-07-13 22:59:36 +08:00
committed by GitHub
parent 033dd38344
commit 1c18620629
5 changed files with 240 additions and 1 deletions
@@ -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,
});
});
});
@@ -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<string, unknown>,
): Record<string, unknown> {
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();
}
+71 -1
View File
@@ -43,6 +43,10 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js"; import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js"; import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-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"; import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
const router = express.Router(); const router = express.Router();
@@ -164,9 +168,10 @@ registerHostInternalRoutes(router);
* description: Failed to save SSH data. * description: Failed to save SSH data.
*/ */
router.post( router.post(
"/db/host", ["/db/host", "/enroll"],
authenticateJWT, authenticateJWT,
requireDataAccess, requireDataAccess,
requireHostEnrollmentAccessForPath,
upload.single("key"), upload.single("key"),
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
@@ -199,6 +204,10 @@ router.post(
hostData = req.body; hostData = req.body;
} }
if (req.path === "/enroll") {
hostData = applyHostEnrollmentDefaults(hostData);
}
const { const {
connectionType, connectionType,
name, 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 * @openapi
* /host/quick-connect: * /host/quick-connect:
+2
View File
@@ -42,6 +42,7 @@ interface WrappedDataKey {
interface AuthenticatedRequest extends Request { interface AuthenticatedRequest extends Request {
userId?: string; userId?: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string;
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKey?: Buffer; dataKey?: Buffer;
} }
@@ -881,6 +882,7 @@ class AuthManager {
}); });
req.userId = matchedKey.userId; req.userId = matchedKey.userId;
req.apiKeyId = matchedKey.id;
next(); next();
} catch (error) { } catch (error) {
databaseLogger.error("API key authentication failed", error, { databaseLogger.error("API key authentication failed", error, {
+1
View File
@@ -955,6 +955,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
userId: string; userId: string;
sessionId?: string; sessionId?: string;
apiKeyId?: string;
user?: { user?: {
id: string; id: string;
username: string; username: string;