import { v } from "convex/values" import { mutation, query } from "./_generated/server" import type { Id, Doc } from "./_generated/dataModel" import { sha256 } from "@noble/hashes/sha256" const DEFAULT_TENANT_ID = "default" function toHex(input: Uint8Array) { return Array.from(input) .map((b) => b.toString(16).padStart(2, "0")) .join("") } function hashToken(token: string) { return toHex(sha256(token)) } export const USB_POLICY_VALUES = ["ALLOW", "BLOCK_ALL", "READONLY"] as const export type UsbPolicyValue = (typeof USB_POLICY_VALUES)[number] export const USB_POLICY_STATUS = ["PENDING", "APPLYING", "APPLIED", "FAILED"] as const export type UsbPolicyStatus = (typeof USB_POLICY_STATUS)[number] export const setUsbPolicy = mutation({ args: { machineId: v.id("machines"), policy: v.string(), actorId: v.optional(v.id("users")), actorEmail: v.optional(v.string()), actorName: v.optional(v.string()), }, handler: async (ctx, args) => { const machine = await ctx.db.get(args.machineId) if (!machine) { throw new Error("Dispositivo nao encontrado") } if (!USB_POLICY_VALUES.includes(args.policy as UsbPolicyValue)) { throw new Error(`Politica USB invalida: ${args.policy}. Valores validos: ${USB_POLICY_VALUES.join(", ")}`) } const now = Date.now() const oldPolicy = machine.usbPolicy ?? "ALLOW" await ctx.db.patch(args.machineId, { usbPolicy: args.policy, usbPolicyStatus: "PENDING", usbPolicyError: undefined, usbPolicyAppliedAt: now, updatedAt: now, }) await ctx.db.insert("usbPolicyEvents", { tenantId: machine.tenantId, machineId: args.machineId, actorId: args.actorId, actorEmail: args.actorEmail, actorName: args.actorName, oldPolicy, newPolicy: args.policy, status: "PENDING", createdAt: now, }) return { ok: true, policy: args.policy, status: "PENDING" } }, }) export const reportUsbPolicyStatus = mutation({ args: { machineToken: v.string(), status: v.string(), // Rust envia null para Option::None, entao precisamos aceitar null tambem error: v.optional(v.union(v.string(), v.null())), currentPolicy: v.optional(v.union(v.string(), v.null())), }, handler: async (ctx, args) => { // Converte null para undefined para uso interno const errorValue = args.error ?? undefined const currentPolicyValue = args.currentPolicy ?? undefined const tokenHash = hashToken(args.machineToken) const tokenRecord = await ctx.db .query("machineTokens") .withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash)) .first() if (!tokenRecord || tokenRecord.revoked) { throw new Error("Token de maquina invalido ou revogado") } if (tokenRecord.expiresAt < Date.now()) { throw new Error("Token de maquina expirado") } const machine = await ctx.db.get(tokenRecord.machineId) if (!machine) { throw new Error("Dispositivo nao encontrado") } if (!USB_POLICY_STATUS.includes(args.status as UsbPolicyStatus)) { throw new Error(`Status de politica USB invalido: ${args.status}`) } const now = Date.now() await ctx.db.patch(machine._id, { usbPolicyStatus: args.status, usbPolicyError: errorValue, usbPolicyReportedAt: now, updatedAt: now, }) const latestEvent = await ctx.db .query("usbPolicyEvents") .withIndex("by_machine_created", (q) => q.eq("machineId", machine._id)) .order("desc") .first() // Atualiza o evento se ainda nao foi finalizado (PENDING ou APPLYING) // Isso permite a transicao: PENDING -> APPLYING -> APPLIED/FAILED if (latestEvent && (latestEvent.status === "PENDING" || latestEvent.status === "APPLYING")) { await ctx.db.patch(latestEvent._id, { status: args.status, error: errorValue, appliedAt: args.status === "APPLIED" ? now : undefined, }) } return { ok: true } }, }) export const getUsbPolicy = query({ args: { machineId: v.id("machines"), }, handler: async (ctx, args) => { const machine = await ctx.db.get(args.machineId) if (!machine) { return null } return { policy: machine.usbPolicy ?? "ALLOW", status: machine.usbPolicyStatus ?? null, error: machine.usbPolicyError ?? null, appliedAt: machine.usbPolicyAppliedAt ?? null, reportedAt: machine.usbPolicyReportedAt ?? null, } }, }) export const getPendingUsbPolicy = query({ args: { machineToken: v.string(), }, handler: async (ctx, args) => { const tokenHash = hashToken(args.machineToken) const tokenRecord = await ctx.db .query("machineTokens") .withIndex("by_token_hash", (q) => q.eq("tokenHash", tokenHash)) .first() if (!tokenRecord || tokenRecord.revoked || tokenRecord.expiresAt < Date.now()) { return null } const machine = await ctx.db.get(tokenRecord.machineId) if (!machine) { return null } if (machine.usbPolicyStatus === "PENDING") { return { policy: machine.usbPolicy ?? "ALLOW", appliedAt: machine.usbPolicyAppliedAt, } } return null }, }) export const listUsbPolicyEvents = query({ args: { machineId: v.id("machines"), limit: v.optional(v.number()), cursor: v.optional(v.number()), status: v.optional(v.string()), dateFrom: v.optional(v.number()), dateTo: v.optional(v.number()), }, handler: async (ctx, args) => { const limit = args.limit ?? 10 let events = await ctx.db .query("usbPolicyEvents") .withIndex("by_machine_created", (q) => q.eq("machineId", args.machineId)) .order("desc") .collect() // Aplica filtro de cursor (paginacao) if (args.cursor !== undefined) { events = events.filter((e) => e.createdAt < args.cursor!) } // Aplica filtro de status if (args.status) { events = events.filter((e) => e.status === args.status) } // Aplica filtro de data if (args.dateFrom !== undefined) { events = events.filter((e) => e.createdAt >= args.dateFrom!) } if (args.dateTo !== undefined) { events = events.filter((e) => e.createdAt <= args.dateTo!) } const hasMore = events.length > limit const results = events.slice(0, limit) const nextCursor = results.length > 0 ? results[results.length - 1].createdAt : undefined return { events: results.map((event) => ({ id: event._id, oldPolicy: event.oldPolicy, newPolicy: event.newPolicy, status: event.status, error: event.error, actorEmail: event.actorEmail, actorName: event.actorName, createdAt: event.createdAt, appliedAt: event.appliedAt, })), hasMore, nextCursor, } }, }) export const bulkSetUsbPolicy = mutation({ args: { machineIds: v.array(v.id("machines")), policy: v.string(), actorId: v.optional(v.id("users")), actorEmail: v.optional(v.string()), actorName: v.optional(v.string()), }, handler: async (ctx, args) => { if (!USB_POLICY_VALUES.includes(args.policy as UsbPolicyValue)) { throw new Error(`Politica USB invalida: ${args.policy}`) } const now = Date.now() const results: Array<{ machineId: Id<"machines">; success: boolean; error?: string }> = [] for (const machineId of args.machineIds) { try { const machine = await ctx.db.get(machineId) if (!machine) { results.push({ machineId, success: false, error: "Dispositivo nao encontrado" }) continue } const oldPolicy = machine.usbPolicy ?? "ALLOW" await ctx.db.patch(machineId, { usbPolicy: args.policy, usbPolicyStatus: "PENDING", usbPolicyError: undefined, usbPolicyAppliedAt: now, updatedAt: now, }) await ctx.db.insert("usbPolicyEvents", { tenantId: machine.tenantId, machineId, actorId: args.actorId, actorEmail: args.actorEmail, actorName: args.actorName, oldPolicy, newPolicy: args.policy, status: "PENDING", createdAt: now, }) results.push({ machineId, success: true }) } catch (err) { results.push({ machineId, success: false, error: String(err) }) } } return { results, total: args.machineIds.length, successful: results.filter((r) => r.success).length } }, }) /** * Cleanup de policies USB pendentes por mais de 1 hora. * Marca como FAILED com mensagem de timeout. */ export const cleanupStalePendingPolicies = mutation({ args: { staleThresholdMs: v.optional(v.number()), }, handler: async (ctx, args) => { const thresholdMs = args.staleThresholdMs ?? 3600000 // 1 hora por padrao const now = Date.now() const cutoff = now - thresholdMs // Buscar maquinas com status PENDING e appliedAt antigo const allMachines = await ctx.db.query("machines").collect() const staleMachines = allMachines.filter( (m) => m.usbPolicyStatus === "PENDING" && m.usbPolicyAppliedAt !== undefined && m.usbPolicyAppliedAt < cutoff ) let cleaned = 0 for (const machine of staleMachines) { await ctx.db.patch(machine._id, { usbPolicyStatus: "FAILED", usbPolicyError: "Timeout: Agent nao reportou status apos 1 hora. Verifique se o agent esta ativo.", updatedAt: now, }) // Atualizar evento correspondente const latestEvent = await ctx.db .query("usbPolicyEvents") .withIndex("by_machine_created", (q) => q.eq("machineId", machine._id)) .order("desc") .first() if (latestEvent && latestEvent.status === "PENDING") { await ctx.db.patch(latestEvent._id, { status: "FAILED", error: "Timeout automatico", }) } cleaned++ } return { cleaned, checked: allMachines.length } }, })