sistema-de-chamados/convex/usbPolicy.ts
esdrasrenan 873305fa7f Improve USB policy history with filters and pagination
- Fix bug where APPLYING status would not transition to APPLIED
- Add status and date range filters to policy history
- Add cursor-based pagination with "Load more" button
- Use DateRangeButton component for date filtering
- Reset filters and pagination when switching filters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-06 23:24:23 -03:00

349 lines
9.9 KiB
TypeScript

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<String>::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 }
},
})