@noble/hashes v2 no longer accepts strings directly, only Uint8Array. Added utf8() helper to encode strings before hashing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
351 lines
9.9 KiB
TypeScript
351 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/sha2.js"
|
|
|
|
const DEFAULT_TENANT_ID = "default"
|
|
|
|
function toHex(input: Uint8Array) {
|
|
return Array.from(input)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
}
|
|
|
|
const utf8 = (s: string) => new TextEncoder().encode(s)
|
|
|
|
function hashToken(token: string) {
|
|
return toHex(sha256(utf8(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 }
|
|
},
|
|
})
|