Rust serializes Option<String>::None as null, not undefined. Updated reportUsbPolicyStatus mutation to accept both null and undefined for error and currentPolicy fields. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
317 lines
8.8 KiB
TypeScript
317 lines
8.8 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()
|
|
|
|
if (latestEvent && latestEvent.status === "PENDING") {
|
|
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()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const limit = args.limit ?? 50
|
|
|
|
const events = await ctx.db
|
|
.query("usbPolicyEvents")
|
|
.withIndex("by_machine_created", (q) => q.eq("machineId", args.machineId))
|
|
.order("desc")
|
|
.take(limit)
|
|
|
|
return events.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,
|
|
}))
|
|
},
|
|
})
|
|
|
|
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 }
|
|
},
|
|
})
|