Saltar al contenido
Microsoft scripts

Scripts de Azure

Asignaciones privilegiadas por suscripción

Lista quién tiene Propietario, Colaborador, Administrador de acceso de usuarios, Administrador de control de acceso basado en roles o Administrador de reservas en cada suscripción, incluidas las heredadas y las asignadas a grupos de recursos o recursos.

Antes de correrloSolo ve asignaciones activas: las elegibles de Privileged Identity Management no aparecen. Mostrar nombres requiere leer el directorio en Microsoft Graph; si tu cuenta no puede, el reporte sale solo con ID. Un grupo cuenta como una asignación: revisa también sus miembros. Si algo no se puede consultar, queda marcado en el reporte como "No se pudo consultar" y el script termina con error para que no lo tomes por completo.

Ejemplo en México

Cadena de farmacias en Guadalajara, 45 sucursales

Se fue un proveedor de TI y nadie sabe qué cuentas siguen como propietarias de las suscripciones.

.\get-asignaciones-privilegiadas.ps1 -RutaCsv .\privilegiados.csv

Identifica cuentas externas con Propietario y le pide a seguridad retirarlas esa misma semana.

Cómo implementarlo

  1. Corre el script con una cuenta con Lector en las suscripciones.
  2. Filtra el CSV por Rol y empieza por Propietario y Administrador de acceso de usuarios.
  3. Si Identidad sale vacía, busca el IdIdentidad en Microsoft Entra ID.
  4. Retira lo que ya no se justifica y deja por escrito quién aprobó cada excepción.
Ver el código completo de get-asignaciones-privilegiadas.ps1
<#
.SYNOPSIS
    ¿Quién tiene Propietario, Colaborador o Administrador de acceso de usuarios en mis suscripciones?

.DESCRIPTION
    Lee las asignaciones de roles de Azure RBAC de cada suscripción y deja solo los cinco roles privilegiados. Intenta mostrar el nombre de cada identidad desde Microsoft Graph. Solo lectura: no cambia nada.

.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-asignaciones-privilegiadas.ps1 -RutaCsv .\privilegiados.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.
#>
[CmdletBinding()]
param(
    [ValidateNotNullOrEmpty()][string[]]$Suscripciones,
    [string]$RutaCsv
)

$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 }

$rolesPrivilegiados = @{
    '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' = 'Propietario'
    'b24988ac-6180-42a0-ab88-20f7382dd24c' = 'Colaborador'
    '18d7d88d-d35e-4fb5-a5c3-7773c20a72d9' = 'Administrador de acceso de usuarios'
    # Estos dos pueden asignarse roles a sí mismos o a otros: equivalen a tener Propietario.
    'f58310d9-a9f6-439a-9e8d-f62e7b41a168' = 'Administrador de control de acceso basado en roles'
    'a8889054-8d42-49c9-bc1c-52486c10e7cd' = 'Administrador de reservas'
}

function DescribirAmbito($Ambito, $IdSuscripcion) {
    if ($Ambito -eq "/subscriptions/$IdSuscripcion") { 'Suscripción' }
    elseif ($Ambito -match '^/subscriptions/[^/]+/resourceGroups/[^/]+$') { 'Grupo de recursos' }
    elseif ($Ambito -match '^/subscriptions/') { 'Recurso' }
    else { 'Heredada (grupo de administración o raíz)' }
}

$sinConsultar = @()
$asignaciones = @(foreach ($s in $activas) {
    try {
        foreach ($a in ListarPaginado "/subscriptions/$($s.subscriptionId)/providers/Microsoft.Authorization/roleAssignments?api-version=2022-04-01") {
            $rol = $rolesPrivilegiados[($a.properties.roleDefinitionId -split '/')[-1]]
            if ($rol) { [pscustomobject]@{ Suscripcion = $s.displayName; IdSuscripcion = $s.subscriptionId; Rol = $rol; Datos = $a.properties } }
        }
    } catch {
        Write-Warning "No se pudo consultar $($s.displayName): $_"
        $sinConsultar += $s.displayName
    }
})

# Los nombres son un extra: sin permiso en Microsoft Graph, el reporte sigue con los ID.
$identidades = @{}
$ids = @($asignaciones | ForEach-Object { $_.Datos.principalId } | Sort-Object -Unique)
try {
    for ($i = 0; $i -lt $ids.Count; $i += 1000) {
        $lote = @($ids[$i..([Math]::Min($i + 999, $ids.Count - 1))])
        foreach ($objeto in (InvocarAzure POST 'https://graph.microsoft.com/v1.0/directoryObjects/getByIds' @{ ids = $lote }).value) {
            $identidades[$objeto.id] = $objeto.displayName
        }
    }
} catch {
    Write-Warning "No se pudieron resolver los nombres en Microsoft Graph: $_"
}

$filas = @(foreach ($a in $asignaciones) {
    [pscustomobject]@{
        Suscripcion  = $a.Suscripcion
        Rol          = $a.Rol
        Identidad    = $identidades[$a.Datos.principalId]
        TipoIdentidad = $a.Datos.principalType
        IdIdentidad  = $a.Datos.principalId
        Nivel        = DescribirAmbito $a.Datos.scope $a.IdSuscripcion
        Ambito       = $a.Datos.scope
    }
})

Write-Host "Asignaciones privilegiadas: $($filas.Count)"
$filas | Group-Object Rol | ForEach-Object { Write-Host "  $($_.Name): $($_.Count)" }

if ($sinConsultar) {
    Write-Host "  No se pudo consultar: $(@($sinConsultar).Count)"
    $filas += @($sinConsultar | ForEach-Object { [pscustomobject]@{ Suscripcion = $_; Rol = 'No se pudo consultar'; Identidad = ''; TipoIdentidad = ''; IdIdentidad = ''; Nivel = ''; Ambito = '' } })
}
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 ', ')" }