From 49aa143a8049acb5212d919782b3ef8eaada492b Mon Sep 17 00:00:00 2001 From: rever-tecnologia Date: Thu, 4 Dec 2025 13:30:59 -0300 Subject: [PATCH] Add USB storage device control feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add USB policy fields to machines schema (policy, status, error) - Create usbPolicyEvents table for audit logging - Implement Convex mutations/queries for USB policy management - Add REST API endpoints for desktop agent communication - Create Rust usb_control module for Windows registry manipulation - Integrate USB policy check in agent heartbeat loop - Add USB policy control component in admin device overview - Add localhost:3001 to auth trustedOrigins for dev 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/desktop/src-tauri/Cargo.lock | 3 + apps/desktop/src-tauri/Cargo.toml | 3 + apps/desktop/src-tauri/src/agent.rs | 109 +++++++ apps/desktop/src-tauri/src/lib.rs | 27 +- apps/desktop/src-tauri/src/usb_control.rs | 296 ++++++++++++++++++ convex/schema.ts | 22 ++ convex/usbPolicy.ts | 251 +++++++++++++++ src/app/api/machines/usb-policy/route.ts | 97 ++++++ .../admin/devices/admin-devices-overview.tsx | 11 + .../admin/devices/usb-policy-control.tsx | 296 ++++++++++++++++++ src/lib/auth.ts | 2 + 11 files changed, 1116 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src-tauri/src/usb_control.rs create mode 100644 convex/usbPolicy.ts create mode 100644 src/app/api/machines/usb-policy/route.ts create mode 100644 src/components/admin/devices/usb-policy-control.tsx diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 20b4252..700f95c 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -69,6 +69,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "sysinfo", "tauri", "tauri-build", @@ -1094,6 +1095,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -3291,6 +3293,7 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "futures-channel", "futures-core", "futures-util", "http", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 285d5f5..b7466f2 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -36,3 +36,6 @@ parking_lot = "0.12" hostname = "0.4" base64 = "0.22" sha2 = "0.10" + +[target.'cfg(windows)'.dependencies] +winreg = "0.55" diff --git a/apps/desktop/src-tauri/src/agent.rs b/apps/desktop/src-tauri/src/agent.rs index 67ddf75..1ceeb1d 100644 --- a/apps/desktop/src-tauri/src/agent.rs +++ b/apps/desktop/src-tauri/src/agent.rs @@ -1129,6 +1129,109 @@ async fn post_heartbeat( Ok(()) } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsbPolicyResponse { + pending: bool, + policy: Option, + applied_at: Option, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct UsbPolicyStatusReport { + machine_token: String, + status: String, + error: Option, + current_policy: Option, +} + +async fn check_and_apply_usb_policy(base_url: &str, token: &str) { + let url = format!("{}/api/machines/usb-policy?machineToken={}", base_url, token); + + let response = match HTTP_CLIENT.get(&url).send().await { + Ok(resp) => resp, + Err(e) => { + eprintln!("[agent] Falha ao verificar politica USB: {e}"); + return; + } + }; + + let policy_response: UsbPolicyResponse = match response.json().await { + Ok(data) => data, + Err(e) => { + eprintln!("[agent] Falha ao parsear resposta de politica USB: {e}"); + return; + } + }; + + if !policy_response.pending { + return; + } + + let policy_str = match policy_response.policy { + Some(p) => p, + None => { + eprintln!("[agent] Politica USB pendente mas sem valor de policy"); + return; + } + }; + + eprintln!("[agent] Aplicando politica USB: {}", policy_str); + + #[cfg(target_os = "windows")] + { + use crate::usb_control::{apply_usb_policy, UsbPolicy}; + + let policy = match UsbPolicy::from_str(&policy_str) { + Some(p) => p, + None => { + eprintln!("[agent] Politica USB invalida: {}", policy_str); + report_usb_policy_status(base_url, token, "FAILED", Some(format!("Politica invalida: {}", policy_str)), None).await; + return; + } + }; + + match apply_usb_policy(policy) { + Ok(result) => { + eprintln!("[agent] Politica USB aplicada com sucesso: {:?}", result); + report_usb_policy_status(base_url, token, "APPLIED", None, Some(policy_str)).await; + } + Err(e) => { + eprintln!("[agent] Falha ao aplicar politica USB: {e}"); + report_usb_policy_status(base_url, token, "FAILED", Some(e.to_string()), None).await; + } + } + } + + #[cfg(not(target_os = "windows"))] + { + eprintln!("[agent] Controle de USB nao suportado neste sistema operacional"); + report_usb_policy_status(base_url, token, "FAILED", Some("Sistema operacional nao suportado".to_string()), None).await; + } +} + +async fn report_usb_policy_status( + base_url: &str, + token: &str, + status: &str, + error: Option, + current_policy: Option, +) { + let url = format!("{}/api/machines/usb-policy", base_url); + + let report = UsbPolicyStatusReport { + machine_token: token.to_string(), + status: status.to_string(), + error, + current_policy, + }; + + if let Err(e) = HTTP_CLIENT.post(&url).json(&report).send().await { + eprintln!("[agent] Falha ao reportar status de politica USB: {e}"); + } +} + struct HeartbeatHandle { token: String, base_url: String, @@ -1198,6 +1301,9 @@ impl AgentRuntime { eprintln!("[agent] Falha inicial ao enviar heartbeat: {error}"); } + // Verifica politica USB apos heartbeat inicial + check_and_apply_usb_policy(&base_clone, &token_clone).await; + let mut ticker = tokio::time::interval(Duration::from_secs(interval)); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); @@ -1215,6 +1321,9 @@ impl AgentRuntime { { eprintln!("[agent] Falha ao enviar heartbeat: {error}"); } + + // Verifica politica USB apos cada heartbeat + check_and_apply_usb_policy(&base_clone, &token_clone).await; } }); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 51bdaf5..454e9d7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ mod agent; #[cfg(target_os = "windows")] mod rustdesk; +mod usb_control; use agent::{collect_inventory_plain, collect_profile, AgentRuntime, MachineProfile}; +use usb_control::{UsbPolicy, UsbPolicyResult}; use tauri::{Emitter, Manager, WindowEvent}; use tauri_plugin_store::Builder as StorePluginBuilder; #[cfg(target_os = "windows")] @@ -100,6 +102,26 @@ fn run_rustdesk_ensure( Err("Provisionamento automático do RustDesk está disponível apenas no Windows.".to_string()) } +#[tauri::command] +fn apply_usb_policy(policy: String) -> Result { + let policy_enum = UsbPolicy::from_str(&policy) + .ok_or_else(|| format!("Politica USB invalida: {}. Use ALLOW, BLOCK_ALL ou READONLY.", policy))?; + + usb_control::apply_usb_policy(policy_enum).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn get_usb_policy() -> Result { + usb_control::get_current_policy() + .map(|p| p.as_str().to_string()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn refresh_usb_policy() -> Result<(), String> { + usb_control::refresh_group_policy().map_err(|e| e.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -128,7 +150,10 @@ pub fn run() { start_machine_agent, stop_machine_agent, open_devtools, - ensure_rustdesk_and_emit + ensure_rustdesk_and_emit, + apply_usb_policy, + get_usb_policy, + refresh_usb_policy ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/apps/desktop/src-tauri/src/usb_control.rs b/apps/desktop/src-tauri/src/usb_control.rs new file mode 100644 index 0000000..5c352b0 --- /dev/null +++ b/apps/desktop/src-tauri/src/usb_control.rs @@ -0,0 +1,296 @@ +//! USB Storage Control Module +//! +//! Este modulo implementa o controle de dispositivos de armazenamento USB no Windows. +//! Utiliza duas abordagens complementares: +//! 1. Removable Storage Access Policy (via registro do Windows) +//! 2. USBSTOR driver control (como fallback/reforco) +//! +//! IMPORTANTE: Requer privilegios de administrador para funcionar. + +use serde::{Deserialize, Serialize}; +use std::io; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum UsbPolicy { + Allow, + BlockAll, + Readonly, +} + +impl UsbPolicy { + pub fn from_str(s: &str) -> Option { + match s.to_uppercase().as_str() { + "ALLOW" => Some(Self::Allow), + "BLOCK_ALL" => Some(Self::BlockAll), + "READONLY" => Some(Self::Readonly), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Allow => "ALLOW", + Self::BlockAll => "BLOCK_ALL", + Self::Readonly => "READONLY", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsbPolicyResult { + pub success: bool, + pub policy: String, + pub error: Option, + pub applied_at: Option, +} + +#[derive(Error, Debug)] +pub enum UsbControlError { + #[error("Politica USB invalida: {0}")] + InvalidPolicy(String), + #[error("Erro de registro do Windows: {0}")] + RegistryError(String), + #[error("Permissao negada - requer privilegios de administrador")] + PermissionDenied, + #[error("Sistema operacional nao suportado")] + UnsupportedOs, + #[error("Erro de I/O: {0}")] + Io(#[from] io::Error), +} + +#[cfg(target_os = "windows")] +mod windows_impl { + use super::*; + use winreg::enums::*; + use winreg::RegKey; + + // GUID para Removable Storage Devices (Disk) + const REMOVABLE_STORAGE_GUID: &str = "{53f56307-b6bf-11d0-94f2-00a0c91efb8b}"; + + // Chaves de registro + const REMOVABLE_STORAGE_PATH: &str = + r"Software\Policies\Microsoft\Windows\RemovableStorageDevices"; + const USBSTOR_PATH: &str = r"SYSTEM\CurrentControlSet\Services\USBSTOR"; + const STORAGE_POLICY_PATH: &str = r"SYSTEM\CurrentControlSet\Control\StorageDevicePolicies"; + + pub fn apply_usb_policy(policy: UsbPolicy) -> Result { + let now = chrono::Utc::now().timestamp_millis(); + + // 1. Aplicar Removable Storage Access Policy + apply_removable_storage_policy(policy)?; + + // 2. Aplicar USBSTOR como reforco + apply_usbstor_policy(policy)?; + + // 3. Aplicar WriteProtect se necessario + if policy == UsbPolicy::Readonly { + apply_write_protect(true)?; + } else { + apply_write_protect(false)?; + } + + Ok(UsbPolicyResult { + success: true, + policy: policy.as_str().to_string(), + error: None, + applied_at: Some(now), + }) + } + + fn apply_removable_storage_policy(policy: UsbPolicy) -> Result<(), UsbControlError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID); + + match policy { + UsbPolicy::Allow => { + // Tenta remover as restricoes, se existirem + if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_ALL_ACCESS) { + let _ = key.delete_value("Deny_Read"); + let _ = key.delete_value("Deny_Write"); + let _ = key.delete_value("Deny_Execute"); + } + // Tenta remover a chave inteira se estiver vazia + let _ = hklm.delete_subkey(&full_path); + } + UsbPolicy::BlockAll => { + let (key, _) = hklm + .create_subkey(&full_path) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + + key.set_value("Deny_Read", &1u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + key.set_value("Deny_Write", &1u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + key.set_value("Deny_Execute", &1u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + } + UsbPolicy::Readonly => { + let (key, _) = hklm + .create_subkey(&full_path) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + + // Permite leitura, bloqueia escrita + key.set_value("Deny_Read", &0u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + key.set_value("Deny_Write", &1u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + key.set_value("Deny_Execute", &0u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + } + } + + Ok(()) + } + + fn apply_usbstor_policy(policy: UsbPolicy) -> Result<(), UsbControlError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + let key = hklm + .open_subkey_with_flags(USBSTOR_PATH, KEY_ALL_ACCESS) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + + match policy { + UsbPolicy::Allow => { + // Start = 3 habilita o driver + key.set_value("Start", &3u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + } + UsbPolicy::BlockAll | UsbPolicy::Readonly => { + // Start = 4 desabilita o driver + // Nota: Para Readonly, mantemos o driver ativo mas com WriteProtect + // Porem, como fallback de seguranca, desabilitamos para BlockAll + if policy == UsbPolicy::BlockAll { + key.set_value("Start", &4u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + } else { + // Readonly mantem driver ativo + key.set_value("Start", &3u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + } + } + } + + Ok(()) + } + + fn apply_write_protect(enable: bool) -> Result<(), UsbControlError> { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + if enable { + let (key, _) = hklm + .create_subkey(STORAGE_POLICY_PATH) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + + key.set_value("WriteProtect", &1u32) + .map_err(|e| UsbControlError::RegistryError(e.to_string()))?; + } else { + if let Ok(key) = hklm.open_subkey_with_flags(STORAGE_POLICY_PATH, KEY_ALL_ACCESS) { + let _ = key.set_value("WriteProtect", &0u32); + } + } + + Ok(()) + } + + pub fn get_current_policy() -> Result { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + + // Verifica Removable Storage Policy primeiro + let full_path = format!(r"{}\{}", REMOVABLE_STORAGE_PATH, REMOVABLE_STORAGE_GUID); + + if let Ok(key) = hklm.open_subkey_with_flags(&full_path, KEY_READ) { + let deny_read: u32 = key.get_value("Deny_Read").unwrap_or(0); + let deny_write: u32 = key.get_value("Deny_Write").unwrap_or(0); + + if deny_read == 1 && deny_write == 1 { + return Ok(UsbPolicy::BlockAll); + } + + if deny_read == 0 && deny_write == 1 { + return Ok(UsbPolicy::Readonly); + } + } + + // Verifica USBSTOR como fallback + if let Ok(key) = hklm.open_subkey_with_flags(USBSTOR_PATH, KEY_READ) { + let start: u32 = key.get_value("Start").unwrap_or(3); + if start == 4 { + return Ok(UsbPolicy::BlockAll); + } + } + + Ok(UsbPolicy::Allow) + } + + pub fn refresh_group_policy() -> Result<(), UsbControlError> { + use std::os::windows::process::CommandExt; + use std::process::Command; + + const CREATE_NO_WINDOW: u32 = 0x08000000; + + // Executa gpupdate para forcar atualizacao das politicas + let output = Command::new("gpupdate") + .args(["/target:computer", "/force"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .map_err(|e| UsbControlError::Io(e))?; + + if !output.status.success() { + // Nao e critico se falhar, apenas log + eprintln!( + "[usb_control] gpupdate retornou erro: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } +} + +#[cfg(not(target_os = "windows"))] +mod fallback_impl { + use super::*; + + pub fn apply_usb_policy(_policy: UsbPolicy) -> Result { + Err(UsbControlError::UnsupportedOs) + } + + pub fn get_current_policy() -> Result { + Err(UsbControlError::UnsupportedOs) + } + + pub fn refresh_group_policy() -> Result<(), UsbControlError> { + Err(UsbControlError::UnsupportedOs) + } +} + +#[cfg(target_os = "windows")] +pub use windows_impl::*; + +#[cfg(not(target_os = "windows"))] +pub use fallback_impl::*; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_policy_from_str() { + assert_eq!(UsbPolicy::from_str("ALLOW"), Some(UsbPolicy::Allow)); + assert_eq!(UsbPolicy::from_str("BLOCK_ALL"), Some(UsbPolicy::BlockAll)); + assert_eq!(UsbPolicy::from_str("READONLY"), Some(UsbPolicy::Readonly)); + assert_eq!(UsbPolicy::from_str("allow"), Some(UsbPolicy::Allow)); + assert_eq!(UsbPolicy::from_str("invalid"), None); + } + + #[test] + fn test_policy_as_str() { + assert_eq!(UsbPolicy::Allow.as_str(), "ALLOW"); + assert_eq!(UsbPolicy::BlockAll.as_str(), "BLOCK_ALL"); + assert_eq!(UsbPolicy::Readonly.as_str(), "READONLY"); + } +} diff --git a/convex/schema.ts b/convex/schema.ts index d1e8ba8..f9e08df 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -637,6 +637,11 @@ export default defineSchema({ updatedAt: v.number(), registeredBy: v.optional(v.string()), remoteAccess: v.optional(v.any()), + usbPolicy: v.optional(v.string()), // ALLOW | BLOCK_ALL | READONLY + usbPolicyAppliedAt: v.optional(v.number()), + usbPolicyStatus: v.optional(v.string()), // PENDING | APPLIED | FAILED + usbPolicyError: v.optional(v.string()), + usbPolicyReportedAt: v.optional(v.number()), }) .index("by_tenant", ["tenantId"]) .index("by_tenant_company", ["tenantId", "companyId"]) @@ -644,6 +649,23 @@ export default defineSchema({ .index("by_tenant_assigned_email", ["tenantId", "assignedUserEmail"]) .index("by_auth_email", ["authEmail"]), + usbPolicyEvents: defineTable({ + tenantId: v.string(), + machineId: v.id("machines"), + actorId: v.optional(v.id("users")), + actorEmail: v.optional(v.string()), + actorName: v.optional(v.string()), + oldPolicy: v.optional(v.string()), + newPolicy: v.string(), + status: v.string(), // PENDING | APPLIED | FAILED + error: v.optional(v.string()), + appliedAt: v.optional(v.number()), + createdAt: v.number(), + }) + .index("by_machine", ["machineId"]) + .index("by_machine_created", ["machineId", "createdAt"]) + .index("by_tenant_created", ["tenantId", "createdAt"]), + machineAlerts: defineTable({ tenantId: v.string(), machineId: v.id("machines"), diff --git a/convex/usbPolicy.ts b/convex/usbPolicy.ts new file mode 100644 index 0000000..5b53da4 --- /dev/null +++ b/convex/usbPolicy.ts @@ -0,0 +1,251 @@ +import { v } from "convex/values" +import { mutation, query } from "./_generated/server" +import type { Id, Doc } from "./_generated/dataModel" + +const DEFAULT_TENANT_ID = "default" + +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", "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(), + error: v.optional(v.string()), + currentPolicy: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const tokenHash = 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: args.error, + 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: args.error, + 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 = 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 } + }, +}) diff --git a/src/app/api/machines/usb-policy/route.ts b/src/app/api/machines/usb-policy/route.ts new file mode 100644 index 0000000..ff453e6 --- /dev/null +++ b/src/app/api/machines/usb-policy/route.ts @@ -0,0 +1,97 @@ +import { z } from "zod" + +import { api } from "@/convex/_generated/api" +import { createCorsPreflight, jsonWithCors } from "@/server/cors" +import { createConvexClient, ConvexConfigurationError } from "@/server/convex-client" + +const getPolicySchema = z.object({ + machineToken: z.string().min(1), +}) + +const reportStatusSchema = z.object({ + machineToken: z.string().min(1), + status: z.enum(["PENDING", "APPLIED", "FAILED"]), + error: z.string().optional(), + currentPolicy: z.string().optional(), +}) + +const CORS_METHODS = "GET, POST, OPTIONS" + +export async function OPTIONS(request: Request) { + return createCorsPreflight(request.headers.get("origin"), CORS_METHODS) +} + +export async function GET(request: Request) { + const origin = request.headers.get("origin") + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + const url = new URL(request.url) + const machineToken = url.searchParams.get("machineToken") + + if (!machineToken) { + return jsonWithCors({ error: "machineToken e obrigatorio" }, 400, origin, CORS_METHODS) + } + + try { + const pendingPolicy = await client.query(api.usbPolicy.getPendingUsbPolicy, { machineToken }) + + if (!pendingPolicy) { + return jsonWithCors({ pending: false }, 200, origin, CORS_METHODS) + } + + return jsonWithCors({ + pending: true, + policy: pendingPolicy.policy, + appliedAt: pendingPolicy.appliedAt, + }, 200, origin, CORS_METHODS) + } catch (error) { + console.error("[machines.usb-policy] Falha ao buscar politica USB", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao buscar politica USB", details }, 500, origin, CORS_METHODS) + } +} + +export async function POST(request: Request) { + const origin = request.headers.get("origin") + + let client + try { + client = createConvexClient() + } catch (error) { + if (error instanceof ConvexConfigurationError) { + return jsonWithCors({ error: error.message }, 500, origin, CORS_METHODS) + } + throw error + } + + let payload + try { + const raw = await request.json() + payload = reportStatusSchema.parse(raw) + } catch (error) { + return jsonWithCors( + { error: "Payload invalido", details: error instanceof Error ? error.message : String(error) }, + 400, + origin, + CORS_METHODS + ) + } + + try { + const response = await client.mutation(api.usbPolicy.reportUsbPolicyStatus, payload) + return jsonWithCors(response, 200, origin, CORS_METHODS) + } catch (error) { + console.error("[machines.usb-policy] Falha ao reportar status de politica USB", error) + const details = error instanceof Error ? error.message : String(error) + return jsonWithCors({ error: "Falha ao reportar status", details }, 500, origin, CORS_METHODS) + } +} diff --git a/src/components/admin/devices/admin-devices-overview.tsx b/src/components/admin/devices/admin-devices-overview.tsx index ccd2a26..de8f95a 100644 --- a/src/components/admin/devices/admin-devices-overview.tsx +++ b/src/components/admin/devices/admin-devices-overview.tsx @@ -73,6 +73,7 @@ import type { Id } from "@/convex/_generated/dataModel" import { TicketStatusBadge } from "@/components/tickets/status-badge" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager" +import { UsbPolicyControl } from "@/components/admin/devices/usb-policy-control" import { DatePicker } from "@/components/ui/date-picker" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" @@ -4961,6 +4962,16 @@ export function DeviceDetails({ device }: DeviceDetailsProps) { ) : null} + + {canManageRemoteAccess && device?.osName?.toLowerCase().includes("windows") ? ( + + ) : null} {windowsCpuDetails.length > 0 ? ( diff --git a/src/components/admin/devices/usb-policy-control.tsx b/src/components/admin/devices/usb-policy-control.tsx new file mode 100644 index 0000000..ea4b6e7 --- /dev/null +++ b/src/components/admin/devices/usb-policy-control.tsx @@ -0,0 +1,296 @@ +"use client" + +import { useState, useEffect } from "react" +import { useMutation, useQuery } from "convex/react" +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Usb, Shield, ShieldOff, ShieldAlert, Clock, CheckCircle2, XCircle, Loader2, History } from "lucide-react" +import { toast } from "sonner" +import { formatDistanceToNow } from "date-fns" +import { ptBR } from "date-fns/locale" + +type UsbPolicyValue = "ALLOW" | "BLOCK_ALL" | "READONLY" + +const POLICY_OPTIONS: Array<{ value: UsbPolicyValue; label: string; description: string; icon: typeof Shield }> = [ + { + value: "ALLOW", + label: "Permitido", + description: "Acesso total a dispositivos USB de armazenamento", + icon: Shield, + }, + { + value: "BLOCK_ALL", + label: "Bloqueado", + description: "Nenhum acesso a dispositivos USB de armazenamento", + icon: ShieldOff, + }, + { + value: "READONLY", + label: "Somente leitura", + description: "Permite leitura, bloqueia escrita em dispositivos USB", + icon: ShieldAlert, + }, +] + +function getPolicyConfig(policy: string | undefined | null) { + return POLICY_OPTIONS.find((opt) => opt.value === policy) ?? POLICY_OPTIONS[0] +} + +function getStatusBadge(status: string | undefined | null) { + switch (status) { + case "PENDING": + return ( + + + Pendente + + ) + case "APPLIED": + return ( + + + Aplicado + + ) + case "FAILED": + return ( + + + Falhou + + ) + default: + return null + } +} + +interface UsbPolicyControlProps { + machineId: string + machineName?: string + actorEmail?: string + actorName?: string + actorId?: string + disabled?: boolean +} + +export function UsbPolicyControl({ + machineId, + machineName, + actorEmail, + actorName, + actorId, + disabled = false, +}: UsbPolicyControlProps) { + const [selectedPolicy, setSelectedPolicy] = useState("ALLOW") + const [isApplying, setIsApplying] = useState(false) + const [showHistory, setShowHistory] = useState(false) + + const usbPolicy = useQuery(api.usbPolicy.getUsbPolicy, { + machineId: machineId as Id<"machines">, + }) + + const policyEvents = useQuery( + api.usbPolicy.listUsbPolicyEvents, + showHistory ? { machineId: machineId as Id<"machines">, limit: 10 } : "skip" + ) + + const setUsbPolicyMutation = useMutation(api.usbPolicy.setUsbPolicy) + + useEffect(() => { + if (usbPolicy?.policy) { + setSelectedPolicy(usbPolicy.policy as UsbPolicyValue) + } + }, [usbPolicy?.policy]) + + const currentConfig = getPolicyConfig(usbPolicy?.policy) + const CurrentIcon = currentConfig.icon + + const handleApplyPolicy = async () => { + if (selectedPolicy === usbPolicy?.policy) { + toast.info("A politica selecionada ja esta aplicada.") + return + } + + setIsApplying(true) + try { + await setUsbPolicyMutation({ + machineId: machineId as Id<"machines">, + policy: selectedPolicy, + actorId: actorId ? (actorId as Id<"users">) : undefined, + actorEmail, + actorName, + }) + toast.success("Politica USB enviada para aplicacao.") + } catch (error) { + console.error("[usb-policy] Falha ao aplicar politica", error) + toast.error("Falha ao aplicar politica USB. Tente novamente.") + } finally { + setIsApplying(false) + } + } + + const formatEventDate = (timestamp: number) => { + return formatDistanceToNow(new Date(timestamp), { + addSuffix: true, + locale: ptBR, + }) + } + + return ( + + +
+
+ + Controle USB +
+ {usbPolicy?.status && getStatusBadge(usbPolicy.status)} +
+ + Gerencie o acesso a dispositivos de armazenamento USB neste dispositivo. + +
+ +
+
+ +
+
+

{currentConfig.label}

+

{currentConfig.description}

+
+
+ + {usbPolicy?.error && ( +
+

Erro na aplicacao

+

{usbPolicy.error}

+
+ )} + +
+
+ + +
+ + + + + + + {selectedPolicy === usbPolicy?.policy + ? "A politica ja esta aplicada" + : `Aplicar politica "${getPolicyConfig(selectedPolicy).label}"`} + + + +
+ +
+ + + {showHistory && policyEvents && ( +
+ {policyEvents.length === 0 ? ( +

+ Nenhuma alteracao registrada +

+ ) : ( + policyEvents.map((event) => ( +
+
+
+ + {getPolicyConfig(event.oldPolicy).label} + + → + + {getPolicyConfig(event.newPolicy).label} + + {getStatusBadge(event.status)} +
+

+ {event.actorName ?? event.actorEmail ?? "Sistema"} · {formatEventDate(event.createdAt)} +

+ {event.error && ( +

{event.error}

+ )} +
+
+ )) + )} +
+ )} +
+ + {usbPolicy?.reportedAt && ( +

+ Ultimo relato do agente: {formatEventDate(usbPolicy.reportedAt)} +

+ )} +
+
+ ) +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index beb8438..11709f3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -16,6 +16,8 @@ export const auth = betterAuth({ env.NEXT_PUBLIC_APP_URL, process.env.NODE_ENV !== "production" ? "http://localhost:3000" : undefined, process.env.NODE_ENV !== "production" ? "http://127.0.0.1:3000" : undefined, + process.env.NODE_ENV !== "production" ? "http://localhost:3001" : undefined, + process.env.NODE_ENV !== "production" ? "http://127.0.0.1:3001" : undefined, ].filter(Boolean) as string[] ) ),