<# .SYNOPSIS Reúne toda la actividad de auditoría de un usuario en un rango de fechas. .DESCRIPTION Usa Search-UnifiedAuditLog con -UserIds y paginación por sesión (ReturnLargeSet, hasta 50,000 registros). Sirve para revisar a alguien que deja la empresa o una posible vulneración. Las fechas se interpretan en -ZonaHoraria y se consultan en UTC. Solo lectura: no cambia nada en el tenant. .PARAMETER Usuario Cuenta del usuario (UserPrincipalName). .PARAMETER Desde Inicio del rango, en la zona horaria indicada. .PARAMETER Hasta Fin del rango, en la zona horaria indicada. Una fecha sin hora incluye ese día completo. Por defecto, ahora. .PARAMETER PriorizarCompletitud Agrega -HighCompleteness: resultados más completos, pero más lentos. Microsoft lo marca en versión preliminar. .PARAMETER ZonaHoraria Zona horaria de las fechas de entrada y de las que se muestran. .PARAMETER RutaCsv Ruta del CSV de salida. Si se omite, solo se muestra en pantalla. .EXAMPLE .\search-actividad-usuario.ps1 -Usuario ana@empresa.example -Desde 2026-10-01 -Hasta 2026-10-31 -RutaCsv .\actividad.csv .NOTES Permisos: ninguno de Graph; usa ExchangeOnlineManagement. Rol mínimo: Audit Logs o View-Only Audit Logs en Exchange Online. Licencia: Microsoft Purview Audit (Standard): retención de 180 días; un año para Exchange, SharePoint, OneDrive y Microsoft Entra con usuarios E5 o complemento equivalente. Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Usuario, [Parameter(Mandatory)] [datetime]$Desde, [datetime]$Hasta, [switch]$PriorizarCompletitud, [string]$ZonaHoraria = 'Central Standard Time (Mexico)', [string]$RutaCsv ) $ErrorActionPreference = 'Stop' $zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria) $limite = 50000 if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) { throw 'Falta el módulo. Instálalo con: Install-Module ExchangeOnlineManagement -Scope CurrentUser' } function AUtc([datetime]$fecha) { [TimeZoneInfo]::ConvertTimeToUtc([datetime]::SpecifyKind($fecha, 'Unspecified'), $zona) } function HoraLocal($valor) { if (-not $valor) { return '' } $utc = if ($valor -is [datetime]) { if ($valor.Kind -eq 'Unspecified') { [datetime]::SpecifyKind($valor, 'Utc') } else { $valor.ToUniversalTime() } } else { [datetimeoffset]::Parse("$valor", [cultureinfo]::InvariantCulture, 'AssumeUniversal').UtcDateTime } [TimeZoneInfo]::ConvertTimeFromUtc($utc, $zona).ToString('yyyy-MM-dd HH:mm') } $inicioUtc = AUtc $Desde $ahoraUtc = (Get-Date).ToUniversalTime() # -Hasta 2026-10-31 se lee como "hasta ese día", no como la medianoche con la que empieza. if ($PSBoundParameters.ContainsKey('Hasta') -and $Hasta.TimeOfDay -eq [timespan]::Zero) { $Hasta = $Hasta.AddDays(1) } $finUtc = if ($PSBoundParameters.ContainsKey('Hasta')) { AUtc $Hasta } else { $ahoraUtc } if ($finUtc -gt $ahoraUtc) { $finUtc = $ahoraUtc } if ($inicioUtc -ge $finUtc) { throw '-Desde debe ser anterior a -Hasta.' } if (((Get-Date).ToUniversalTime() - $inicioUtc).TotalDays -gt 180) { Write-Warning 'Sin licencia E5 o complemento equivalente, la auditoría solo conserva 180 días.' } $filtro = @{ StartDate = $inicioUtc; EndDate = $finUtc; UserIds = $Usuario } if ($PriorizarCompletitud) { $filtro.HighCompleteness = $true } Connect-ExchangeOnline -ShowBanner:$false $sesion = [guid]::NewGuid().ToString() $vistos = [System.Collections.Generic.HashSet[string]]::new() $registros = [System.Collections.Generic.List[object]]::new() try { # Con la auditoría apagada Search-UnifiedAuditLog no devuelve nada, y cero parecería un resultado limpio. if (Get-Command Get-AdminAuditLogConfig -ErrorAction SilentlyContinue) { if ((Get-AdminAuditLogConfig).UnifiedAuditLogIngestionEnabled -ne $true) { throw 'La auditoría unificada está apagada: este reporte no puede ver nada. Actívala y vuelve a correrlo.' } } else { Write-Warning 'Tu rol no permite comprobar si la auditoría está activa. Un resultado vacío podría no significar nada: confírmalo con get-estado-auditoria.' } # Se pide hasta que no llegue nada nuevo; siempre el mismo SessionCommand para no bajar el tope a 10,000. do { $pagina = @(Search-UnifiedAuditLog @filtro -SessionId $sesion -SessionCommand ReturnLargeSet -ResultSize 5000) $nuevos = @($pagina | Where-Object { $vistos.Add([string]$_.Identity) }) foreach ($r in $nuevos) { $registros.Add($r) } } while ($nuevos.Count -and $registros.Count -lt $limite) } finally { Disconnect-ExchangeOnline -Confirm:$false } if ($registros.Count -ge $limite) { Write-Warning "Se alcanzó el tope de $limite registros: pueden faltar eventos. Divide el rango de fechas." } $filas = @( foreach ($r in $registros) { $a = try { $r.AuditData | ConvertFrom-Json } catch { $null } [pscustomobject]@{ Fecha = if ($a.CreationTime) { HoraLocal $a.CreationTime } else { "$($r.CreationDate)" } Usuario = $r.UserIds Operacion = if ($a.Operation) { $a.Operation } else { $r.Operations } Servicio = $a.Workload Objeto = $a.ObjectId IP = $a.ClientIP Resultado = $a.ResultStatus Registro = $r.Identity } } ) | Sort-Object Fecha Write-Host "Eventos de $Usuario : $(@($filas).Count)." if (-not $registros.Count) { Write-Warning "Sin eventos en ese rango. Revisa que $Usuario esté bien escrito: un UPN inexistente también da cero." } if ($RutaCsv) { # Destinatarios y nombres de archivo vienen de fuera: Excel ejecutaría como fórmula los que empiecen así. foreach ($f in $filas) { foreach ($p in $f.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'" + $p.Value } } } # UTF-8 con BOM para que Excel en español muestre bien los acentos. $filas | Export-Csv -Path $RutaCsv -NoTypeInformation -Encoding utf8BOM Write-Host "CSV guardado en $RutaCsv" } else { $filas | Format-Table -AutoSize }