Administradores con roles activos y su MFA
Muestra quién tiene hoy un rol de directorio activo, directo o por grupo, y si tiene MFA registrado. Sirve para encontrar administradores de más y cuentas privilegiadas sin protección.
Solo lecturaPide 4 permisosProbado con respuestas simuladas. Sin ejecutar en un tenant real.
Ejemplo en México
Manufactura en Querétaro, 400 personas
La auditoría del corporativo pide la lista de quién tiene privilegios en Microsoft 365 y con qué protección, y el equipo de TI solo tiene un archivo de hace dos años.
.\get-admins-privilegiados.ps1 -RutaCsv .\admins.csvEncuentra nueve administradores globales, dos sin MFA y un exempleado que heredó el rol por un grupo. Entrega el CSV como evidencia y reduce la lista a tres.
Cómo implementarlo
- Abre PowerShell 7 con el módulo Microsoft.Graph.Authentication instalado.
- Ejecuta el script con -RutaCsv e inicia sesión con una cuenta con el rol Lector global.
- Filtra el CSV por MfaRegistrado en No y por el rol Global Administrator.
- Registra el MFA que falte y quita los roles que ya no se justifiquen.
Ver el código completo de get-admins-privilegiados.ps1
<#
.SYNOPSIS
¿Quién tiene roles de directorio activos y tiene MFA registrado?
.DESCRIPTION
Lista las asignaciones activas de roles de Microsoft Entra (permanentes y elegibles ya activadas en PIM),
abre los grupos con rol para ver a sus miembros y cruza cada usuario con el reporte de registro de MFA.
Las asignaciones elegibles que no están activadas no aparecen.
Solo lectura: no cambia nada en el tenant.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.EXAMPLE
.\get-admins-privilegiados.ps1 -RutaCsv .\admins.csv
.NOTES
Permisos: RoleManagement.Read.Directory, AuditLog.Read.All, User.Read.All, GroupMember.Read.All
Rol mínimo: Lector global (Global Reader)
Licencia: Microsoft Entra ID P1
Licencia del código: MIT. Se entrega TAL CUAL, sin garantía. Pruébalo primero en un tenant de pruebas.
#>
#Requires -Version 7.0
[CmdletBinding()]
param(
[string]$RutaCsv
)
$ErrorActionPreference = 'Stop'
$permisos = @('RoleManagement.Read.Directory', 'AuditLog.Read.All', 'User.Read.All', 'GroupMember.Read.All')
if (-not (Get-Command Connect-MgGraph -ErrorAction SilentlyContinue)) {
throw 'Falta el módulo. Instálalo con: Install-Module Microsoft.Graph.Authentication -Scope CurrentUser'
}
Connect-MgGraph -Scopes $permisos -NoWelcome
function Get-TodasLasPaginas {
param([string]$Uri, [hashtable]$Encabezados = @{})
while ($Uri) {
$pagina = Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers $Encabezados
$pagina.value
$Uri = $pagina.'@odata.nextLink'
}
}
$base = 'https://graph.microsoft.com/v1.0'
$roles = @{}
foreach ($d in Get-TodasLasPaginas "$base/roleManagement/directory/roleDefinitions") { $roles[$d.id] = $d.displayName }
$mfa = @{}
foreach ($r in Get-TodasLasPaginas "$base/reports/authenticationMethods/userRegistrationDetails") { $mfa[$r.id] = [bool]$r.isMfaRegistered }
function New-FilaUsuario {
param($Usuario, [string]$Rol, [string]$Alcance, [string]$Via)
[pscustomobject]@{
Nombre = $Usuario.displayName
Cuenta = $Usuario.userPrincipalName
Tipo = 'Usuario'
Rol = $Rol
Alcance = $Alcance
AsignadoPor = $Via
Habilitada = if ($Usuario.accountEnabled -eq $false) { 'No' } elseif ($Usuario.accountEnabled) { 'Sí' } else { 'Sin dato' }
# El reporte de registro no incluye cuentas deshabilitadas.
MfaRegistrado = if ($mfa.ContainsKey($Usuario.id)) { if ($mfa[$Usuario.id]) { 'Sí' } else { 'No' } } else { 'Sin dato' }
}
}
$asignaciones = Get-TodasLasPaginas "$base/roleManagement/directory/roleAssignments?`$expand=principal"
$filas = foreach ($a in $asignaciones) {
$rol = if ($roles[$a.roleDefinitionId]) { $roles[$a.roleDefinitionId] } else { $a.roleDefinitionId }
$alcance = if ($a.directoryScopeId -eq '/') { 'Todo el tenant' } else { $a.directoryScopeId }
$principal = $a.principal
switch ($principal.'@odata.type') {
'#microsoft.graph.user' {
New-FilaUsuario -Usuario $principal -Rol $rol -Alcance $alcance -Via 'Directo'
}
'#microsoft.graph.group' {
# Con conversión OData y $select la API exige ConsistencyLevel y $count.
$miembros = Get-TodasLasPaginas "$base/groups/$($principal.id)/transitiveMembers/microsoft.graph.user?`$count=true&`$select=id,displayName,userPrincipalName,accountEnabled" @{ ConsistencyLevel = 'eventual' }
foreach ($m in $miembros) { New-FilaUsuario -Usuario $m -Rol $rol -Alcance $alcance -Via "Grupo: $($principal.displayName)" }
}
default {
[pscustomobject]@{
Nombre = if ($principal.displayName) { $principal.displayName } else { $a.principalId }
Cuenta = $a.principalId
Tipo = if ($principal.'@odata.type' -eq '#microsoft.graph.servicePrincipal') { 'Aplicación' } else { 'Desconocido' }
Rol = $rol
Alcance = $alcance
AsignadoPor = 'Directo'
Habilitada = 'Sin dato'
MfaRegistrado = 'No aplica'
}
}
}
}
$filas = $filas | Sort-Object Rol, Nombre
Write-Host "Asignaciones activas: $(@($filas).Count). Usuarios con rol y sin MFA: $(@($filas | Where-Object MfaRegistrado -eq 'No').Count)"
if ($RutaCsv) {
# Excel ejecutaría como fórmula un nombre que empiece con estos caracteres.
foreach ($fila in $filas) { foreach ($p in $fila.PSObject.Properties) { if ($p.Value -is [string] -and $p.Value -match '^[=+\-@]') { $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
}