// server/services/actionRequests.ts import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { db, schema } from "../db/client.js"; import { runAction, type RunActionOpts } from "./execute.js"; import { recordEvent } from "./machineState.js"; import type { ActionType } from "@shared/types.js"; // Actions destructives nécessitant une validation explicite (70-securite.md §2). export const DESTRUCTIVE_ACTIONS: Partial> = { docker_compose_apply: "medium", docker_prune_images: "medium", docker_compose_down: "high", apt_full_upgrade: "medium", apt_dist_upgrade: "medium", apt_autoremove: "medium", reboot: "high", reboot_verified: "high", post_install: "high", // risque réel porté par le manifeste du profil }; const NEED_STACK: ActionType[] = ["docker_compose_apply", "docker_compose_down"]; export interface CreateRequestInput { machineId: string; action: ActionType; requestedByType?: "user" | "hermes" | "schedule"; requestedById?: string | null; summary?: string | null; payload?: { stackId?: string; aggressive?: boolean; profileId?: string; values?: Record; } | null; } export function createActionRequest(input: CreateRequestInput) { const risk = DESTRUCTIVE_ACTIONS[input.action]; if (!risk) throw new Error(`Action non destructive ou inconnue : ${input.action}`); if (NEED_STACK.includes(input.action) && !input.payload?.stackId) { throw new Error("stackId requis pour cette action"); } const id = randomUUID(); const now = new Date().toISOString(); db.insert(schema.actionRequests).values({ id, machineId: input.machineId, requestedByType: input.requestedByType ?? "user", requestedById: input.requestedById ?? null, action: input.action, risk, status: "pending", summary: input.summary ?? `Demande ${input.action}`, payloadJson: input.payload ? JSON.stringify(input.payload) : null, createdAt: now, }).run(); recordEvent({ machineId: input.machineId, eventType: "action_request_created", severity: "info", message: `Demande ${input.action} (risque ${risk}) en attente de validation`, }); return getActionRequest(id); } export function getActionRequest(id: string) { return db.select().from(schema.actionRequests).where(eq(schema.actionRequests.id, id)).get(); } export function listActionRequests(machineId?: string) { const q = db.select().from(schema.actionRequests); const rows = machineId ? q.where(eq(schema.actionRequests.machineId, machineId)).all() : q.all(); return rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } export function rejectActionRequest(id: string, by?: string) { const req = getActionRequest(id); if (!req) throw new Error("Demande introuvable"); if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`); db.update(schema.actionRequests) .set({ status: "rejected", approvedAt: new Date().toISOString(), approvedBy: by ?? null }) .where(eq(schema.actionRequests.id, id)) .run(); return getActionRequest(id); } /** * Approuve une demande et déclenche l'action en arrière-plan. Renvoie immédiatement * la demande passée à `approved` ; `executionId`/`executed` sont posés à la fin du run. */ export function approveActionRequest(id: string, approvedBy?: string) { const req = getActionRequest(id); if (!req) throw new Error("Demande introuvable"); if (req.status !== "pending") throw new Error(`Demande déjà ${req.status}`); if (!req.machineId) throw new Error("Demande sans machine"); const now = new Date().toISOString(); db.update(schema.actionRequests) .set({ status: "approved", approvedAt: now, approvedBy: approvedBy ?? null }) .where(eq(schema.actionRequests.id, id)) .run(); const payload = req.payloadJson ? (JSON.parse(req.payloadJson) as { stackId?: string; aggressive?: boolean; profileId?: string; values?: Record }) : {}; const opts: RunActionOpts = { stackId: payload.stackId, aggressive: payload.aggressive, profileId: payload.profileId, values: payload.values, }; const machineId = req.machineId; runAction(machineId, req.action as ActionType, opts) .then((result) => { db.update(schema.actionRequests) .set({ status: "executed", executionId: result.executionId }) .where(eq(schema.actionRequests.id, id)) .run(); }) .catch((err) => { recordEvent({ machineId, eventType: "action_request_failed", severity: "error", message: `Demande ${req.action} échouée : ${(err as Error).message}`, }); }); return getActionRequest(id); }