Borrados y cambios de permisos en el registro de actividad
Revisa el registro de actividad de Azure y te dice quién borró recursos o cambió permisos en los últimos días, hasta el máximo de 90 que Azure conserva.
Solo lecturaPide 1 permisoProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Corporativo de transporte en Toluca, 600 personas
Desapareció un grupo de recursos de un fin de semana a otro y nadie admite haberlo borrado.
.\get-cambios-criticos-actividad.ps1 -Dias 30 -RutaCsv .\cambios.csvVe quién y a qué hora borró el grupo, y aprovecha para revisar los permisos que se dieron ese mes.
Cómo implementarlo
- Ejecuta el script con -Dias según lo que quieras revisar; el máximo es 90.
- Filtra por Tipo y empieza por "Cambio de permisos".
- Confirma con cada Responsable que el cambio corresponde a una solicitud aprobada.
- Si algo no cuadra, abre un incidente; si necesitas más de 90 días, envía el registro a un área de trabajo de Log Analytics.
Ver el código completo de get-cambios-criticos-actividad.ps1
<#
.SYNOPSIS
¿Quién borró recursos o cambió permisos en mis suscripciones en los últimos días?
.DESCRIPTION
Lee el registro de actividad de cada suscripción y deja las operaciones exitosas de borrado y de cambio de asignaciones o definiciones de roles. Las fechas se consultan en UTC y se muestran en -ZonaHoraria. Solo lectura: no cambia nada.
.PARAMETER Dias
Días hacia atrás a revisar, de 1 a 90. Azure conserva el registro de actividad 90 días.
.PARAMETER ZonaHoraria
Zona horaria para mostrar las fechas. Por defecto, Central Standard Time (Mexico).
.PARAMETER Suscripciones
IDs o nombres de suscripción. Si se omite, usa todas a las que tienes acceso.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\get-cambios-criticos-actividad.ps1 -Dias 30 -RutaCsv .\cambios.csv
.NOTES
Permisos: Lector (Reader) en cada suscripción
Rol mínimo: Lector (Reader)
Licencia: Basta una suscripción de Azure; no requiere plan adicional.
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en una suscripción de pruebas.
#>
#Requires -Version 7.0
[CmdletBinding()]
param(
[ValidateRange(1, 90)][int]$Dias = 7,
[string]$ZonaHoraria = 'Central Standard Time (Mexico)',
[ValidateNotNullOrEmpty()][string[]]$Suscripciones,
[string]$RutaCsv
)
$zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria)
$ErrorActionPreference = 'Stop'
if (-not (Get-Command Connect-AzAccount -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module Az.Accounts -Scope CurrentUser'
}
if (-not (Get-AzContext)) { $null = Connect-AzAccount }
function InvocarAzure($Metodo, $Ruta, $Cuerpo) {
$parametros = @{ Method = $Metodo }
if ($Ruta -like 'http*') { $parametros.Uri = $Ruta } else { $parametros.Path = $Ruta }
if ($Cuerpo) { $parametros.Payload = $Cuerpo | ConvertTo-Json -Depth 10 }
# Invoke-AzRestMethod no lanza error con 4xx o 5xx. 429 es una limitación temporal: se reintenta.
for ($intento = 1; ; $intento++) {
$respuesta = Invoke-AzRestMethod @parametros
if ($respuesta.StatusCode -ne 429 -or $intento -ge 4) { break }
Start-Sleep -Seconds 30
}
if ($respuesta.StatusCode -ge 400) { throw "Azure respondió $($respuesta.StatusCode) en ${Ruta}: $($respuesta.Content)" }
if ($respuesta.Content) { $respuesta.Content | ConvertFrom-Json -AsHashtable }
}
function ListarPaginado($Ruta) {
while ($Ruta) {
$pagina = InvocarAzure GET $Ruta
$pagina.value
$Ruta = $pagina.nextLink
}
}
$activas = @(ListarPaginado '/subscriptions?api-version=2022-12-01' | Where-Object {
$_.state -notin 'Disabled', 'Deleted' -and
(-not $Suscripciones -or $_.subscriptionId -in $Suscripciones -or $_.displayName -in $Suscripciones)
})
if (-not $activas) { throw 'No hay suscripciones activas a las que tengas acceso con ese filtro.' }
$noEncontradas = @($Suscripciones | Where-Object { $_ -and $_ -notin $activas.subscriptionId -and $_ -notin $activas.displayName })
if ($noEncontradas) { throw "No se encontró o no está activa: $($noEncontradas -join ', '). Revisa -Suscripciones." }
$nombres = @{}
foreach ($s in $activas) { $nombres[$s.subscriptionId] = $s.displayName }
function ConvertirFecha($Valor) {
if (-not $Valor) { return $null }
$utc = if ($Valor -is [datetime]) { $Valor.ToUniversalTime() } else { ([datetimeoffset]"$Valor").UtcDateTime }
[TimeZoneInfo]::ConvertTimeFromUtc($utc, $zona).ToString('yyyy-MM-dd HH:mm')
}
$ahora = [datetime]::UtcNow
# Un minuto de margen para no pedir más de los 90 días que conserva Azure.
$desde = $ahora.AddDays(-$Dias).AddMinutes(1)
$filtro = "eventTimestamp ge '$($desde.ToString('yyyy-MM-ddTHH:mm:ssZ'))' and eventTimestamp le '$($ahora.ToString('yyyy-MM-ddTHH:mm:ssZ'))'"
$filtro = [uri]::EscapeDataString($filtro)
$sinConsultar = @()
$filas = @(foreach ($s in $activas) {
try {
$eventos = ListarPaginado "/subscriptions/$($s.subscriptionId)/providers/Microsoft.Insights/eventtypes/management/values?api-version=2015-04-01&%24filter=$filtro"
} catch {
Write-Warning "No se pudo consultar $($s.displayName): $_"
$sinConsultar += $s.displayName
continue
}
foreach ($evento in $eventos) {
$operacion = "$($evento.operationName.value)"
if ($evento.status.value -ne 'Succeeded') { continue }
$tipo = if ($operacion -match '^Microsoft\.Authorization/(roleAssignments|roleDefinitions)/(write|delete)$') { 'Cambio de permisos' }
elseif ($operacion -match '/delete$') { 'Borrado' }
if ($tipo) {
[pscustomobject]@{
Fecha = ConvertirFecha $evento.eventTimestamp
Tipo = $tipo
Operacion = $operacion
Responsable = $evento.caller
Suscripcion = $s.displayName
GrupoRecursos = $evento.resourceGroupName
Recurso = $evento.resourceId
}
}
}
})
$filas = @($filas | Sort-Object Fecha -Descending)
Write-Host "Cambios críticos en $Dias días: $($filas.Count)"
$filas | Group-Object Tipo | ForEach-Object { Write-Host " $($_.Name): $($_.Count)" }
if ($sinConsultar) {
Write-Host " No se pudo consultar: $(@($sinConsultar).Count)"
$filas += @($sinConsultar | ForEach-Object { [pscustomobject]@{ Fecha = ''; Tipo = 'No se pudo consultar'; Operacion = ''; Responsable = ''; Suscripcion = $_; GrupoRecursos = ''; Recurso = '' } })
}
if ($filas.Count -eq 0) {
Write-Host 'Sin resultados.'
# Sin esto, el CSV de una corrida anterior parecería el resultado de esta.
if ($RutaCsv) { Set-Content -Path $RutaCsv -Value '' -Encoding utf8BOM }
} elseif ($RutaCsv) {
# 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
}
# El reporte ya quedó escrito; el error avisa que está incompleto.
if ($sinConsultar) { throw "Reporte incompleto, no se pudo consultar: $(@($sinConsultar) -join ', ')" }