mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Add API key host enrollment endpoint (#1029)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -955,6 +955,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
apiKeyId?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
|
||||
Reference in New Issue
Block a user