Invitados obsoletos
Lista a los invitados que no aceptaron la invitación o que llevan N días sin actividad. Sirve para cerrar accesos de proveedores y consultores que ya terminaron.
Ejemplo en México
Constructora en Monterrey, 150 personas
Cada obra suma contratistas como invitados en Teams y nadie los da de baja cuando termina. Ya hay más invitados que empleados.
.\get-invitados-obsoletos.ps1 -Dias 120 -RutaCsv .\invitados.csvIdentifica 80 invitados de obras cerradas y 15 invitaciones nunca aceptadas. Los residentes de obra confirman la lista y TI los elimina.
Cómo implementarlo
- Abre PowerShell 7 con el módulo Microsoft.Graph.Authentication instalado.
- Corre el script con -Dias y -RutaCsv e inicia sesión con una cuenta con el rol Lector de informes.
- Revisa la columna Motivo y agrupa a los invitados por dominio de correo.
- Pregunta a quien los invitó si siguen colaborando, y bloquea o elimina a los que ya no.
Ver el código completo de get-invitados-obsoletos.ps1
<#
.SYNOPSIS
¿Qué invitados no aceptaron la invitación o ya no tienen actividad?
.DESCRIPTION
Revisa a los usuarios invitados y marca tres casos, todos con más de N días: invitación sin aceptar,
invitado que aceptó y nunca tuvo un inicio de sesión correcto registrado, e invitado sin actividad reciente.
Sirve para limpiar accesos externos de proveedores y proyectos que ya terminaron.
Solo lectura: no cambia nada en el tenant.
.PARAMETER RutaCsv
Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.
.PARAMETER Dias
Antigüedad en días para considerar obsoleto a un invitado. Por defecto, 90.
.PARAMETER ZonaHoraria
Zona horaria para mostrar las fechas. Por defecto, Central Standard Time (Mexico).
.EXAMPLE
.\get-invitados-obsoletos.ps1 -Dias 120 -RutaCsv .\invitados.csv
.NOTES
Permisos: AuditLog.Read.All, User.Read.All
Rol mínimo: Lector de informes (Reports 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.
#>
[CmdletBinding()]
param(
[string]$RutaCsv,
[ValidateRange(1, 36500)][int]$Dias = 90,
[string]$ZonaHoraria = 'Central Standard Time (Mexico)'
)
$ErrorActionPreference = 'Stop'
$permisos = @('AuditLog.Read.All', 'User.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'
}
$zona = [TimeZoneInfo]::FindSystemTimeZoneById($ZonaHoraria)
Connect-MgGraph -Scopes $permisos -NoWelcome
function ConvertTo-FechaUtc {
param($Valor)
# Graph puede dar texto o fecha; vacío o el año 0001 significan que no hay dato.
if (-not $Valor) { return $null }
$fecha = if ($Valor -is [datetimeoffset]) { $Valor.UtcDateTime }
elseif ($Valor -is [datetime]) { if ($Valor.Kind -eq 'Unspecified') { [datetime]::SpecifyKind($Valor, 'Utc') } else { $Valor.ToUniversalTime() } }
else { [datetimeoffset]::Parse("$Valor", [cultureinfo]::InvariantCulture).UtcDateTime }
if ($fecha.Year -le 1) { return $null }
$fecha
}
function Format-FechaLocal {
param($FechaUtc)
if ($FechaUtc) { [TimeZoneInfo]::ConvertTimeFromUtc($FechaUtc, $zona).ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture) }
}
$limite = [datetime]::UtcNow.AddDays(-$Dias)
# Con signInActivity la página máxima es de 500.
$uri = 'https://graph.microsoft.com/v1.0/users?$filter=userType eq ''Guest''&$select=id,displayName,userPrincipalName,mail,accountEnabled,createdDateTime,externalUserState,externalUserStateChangeDateTime,signInActivity&$top=500'
$invitados = while ($uri) {
$pagina = Invoke-MgGraphRequest -Method GET -Uri $uri
$pagina.value
$uri = $pagina.'@odata.nextLink'
}
$filas = foreach ($g in $invitados) {
$creado = ConvertTo-FechaUtc $g.createdDateTime
$cambioEstado = ConvertTo-FechaUtc $g.externalUserStateChangeDateTime
$ultimo = ConvertTo-FechaUtc $g.signInActivity.lastSuccessfulSignInDateTime
$motivo = $null
if ($g.externalUserState -eq 'PendingAcceptance') {
$desde = if ($cambioEstado) { $cambioEstado } else { $creado }
if ($desde -and $desde -lt $limite) { $motivo = 'Invitación sin aceptar' }
} elseif ($ultimo) {
if ($ultimo -lt $limite) { $motivo = 'Sin actividad' }
} elseif ($creado -and $creado -lt $limite) {
$motivo = 'Sin inicio correcto registrado'
}
if (-not $motivo) { continue }
[pscustomobject]@{
Nombre = $g.displayName
Correo = $g.mail
Cuenta = $g.userPrincipalName
Motivo = $motivo
EstadoInvitacion = $g.externalUserState
Habilitada = if ($g.accountEnabled) { 'Sí' } else { 'No' }
InvitadoEl = Format-FechaLocal $creado
UltimoInicioOk = Format-FechaLocal $ultimo
}
}
Write-Host "Invitados obsoletos con más de $Dias días: $(@($filas).Count)"
if ($RutaCsv) {
# Los invitados eligen su propio nombre: Excel lo ejecutaría como fórmula si empieza 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
}