Saltar al contenido
MS Scripts

Scripts de Exchange

Permisos delegados en buzones

Lista quién tiene FullAccess, SendAs y SendOnBehalf en cada buzón, sin ruido de permisos del sistema. Con -Usuario responde a qué buzones tiene acceso una persona, la pregunta típica al darla de baja.

Solo lecturaSin permisos de GraphProbado con respuestas simuladas. Sin ejecutar en un tenant real.

Antes de ejecutarloSolo muestra permisos directos: quien entra por un grupo aparece a nombre del grupo, y con -Usuario esa fila no sale, así que un resultado vacío no prueba que no tenga acceso. SendOnBehalf puede guardar el nombre o el alias en lugar del correo. Una consulta por buzón es lenta en tenants grandes. No revisa permisos de carpetas, como el calendario.

Ejemplo en México

Despacho contable en Querétaro, 45 personas

Una contadora deja el despacho en pleno cierre fiscal. Antes de dar de baja la cuenta necesitan saber a qué buzones de clientes entraba.

.\get-permisos-delegados.ps1 -Usuario ana.lopez@empresa.example -RutaCsv .\accesos-ana.csv

Obtiene los buzones donde tiene FullAccess, SendAs o SendOnBehalf y reasigna esos accesos a quien toma su cartera antes de la baja.

Cómo implementarlo

  1. Instala el módulo si no lo tienes: Install-Module ExchangeOnlineManagement -Scope CurrentUser.
  2. Ejecuta el script con -RutaCsv para todo el tenant, o con -Usuario y el UPN de la persona que se va.
  3. Inicia sesión con una cuenta que tenga el rol indicado y espera: revisa cada buzón uno por uno.
  4. Revisa el CSV, quita los permisos que ya no procedan y revisa también los grupos de la persona, porque un acceso por grupo sale a nombre del grupo.
Ver el código completo de get-permisos-delegados.ps1
<#
.SYNOPSIS
    Responde quién tiene acceso a qué buzón, o a qué buzones tiene acceso una persona.

.DESCRIPTION
    Lista los permisos FullAccess, ChangePermission, ChangeOwner, SendAs y SendOnBehalf asignados directamente en cada buzón.
    No revisa permisos de carpetas, como el calendario. Excluye NT AUTHORITY\SELF,
    las cuentas de NT AUTHORITY y los permisos heredados del sistema. Con -Usuario responde la pregunta típica al dar
    de baja a alguien. Un acceso recibido por grupo aparece a nombre del grupo, no de la persona.
    Si un buzón falla, queda en el reporte y el script termina con error al final, después de guardar el CSV.
    Solo lectura: no cambia nada en el tenant.

.PARAMETER Usuario
    UPN o correo de la persona. Muestra solo los buzones donde tiene permisos. Si no existe, el script se detiene.

.PARAMETER Tipo
    Tipo de buzón: UserMailbox, SharedMailbox, RoomMailbox, EquipmentMailbox o Todos (por defecto).

.PARAMETER RutaCsv
    Ruta del CSV de salida. Si se omite, solo se muestra en pantalla.

.EXAMPLE
    .\get-permisos-delegados.ps1 -Usuario ana.lopez@empresa.example -RutaCsv .\accesos-ana.csv

.NOTES
    Permisos: ninguno de Graph; usa ExchangeOnlineManagement.
    Rol mínimo: Lector global (View-Only Organization Management en Exchange Online).
    Licencia: Exchange Online Plan 1 o superior, incluido en Microsoft 365 Business Premium.
    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(
    [ValidateNotNullOrEmpty()]
    [string]$Usuario,

    [ValidateSet('UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox', 'Todos')]
    [string]$Tipo = 'Todos',

    [string]$RutaCsv
)

$ErrorActionPreference = 'Stop'

if (-not (Get-Command Connect-ExchangeOnline -ErrorAction SilentlyContinue)) {
    throw 'Falta el módulo. Instálalo con: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
}

function Limpiar-Fila($fila) {
    foreach ($p in $fila.PSObject.Properties) {
        if ($p.Value -is [string] -and $p.Value -match '^[=+\-@\t\r]') { $p.Value = "'$($p.Value)" }
    }
    $fila
}

# El CSV se vacía desde el inicio: si algo truena o no hay resultados, no queda uno viejo que parezca actual.
if ($RutaCsv) { Set-Content -LiteralPath $RutaCsv -Value $null -Encoding utf8BOM }

$tipos = if ($Tipo -eq 'Todos') { 'UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox' } else { $Tipo }
$filas = [System.Collections.Generic.List[object]]::new()
$fallas = [System.Collections.Generic.List[string]]::new()

Connect-ExchangeOnline -ShowBanner:$false
try {
    $identidades = @()
    if ($Usuario) {
        # GrantSendOnBehalfTo no trae siempre el correo, así que se compara contra todas las formas de nombrar a la persona.
        $persona = Get-EXORecipient -Identity $Usuario -Properties DisplayName, Alias, PrimarySmtpAddress, EmailAddresses -ErrorAction Stop
        if (-not $persona) { throw "No se encontró al destinatario $Usuario. Sin él no se puede afirmar a qué buzones tiene acceso." }
        # El permiso suele guardarse a nombre del UPN, que puede ser distinto del correo.
        $upn = $null
        try { $upn = (Get-User -Identity $Usuario -ErrorAction Stop).UserPrincipalName } catch { Write-Warning "No se pudo leer el UPN de $Usuario : pueden faltar permisos guardados a ese nombre. $_" }
        $identidades = @($Usuario, $upn, $persona.Name, $persona.Alias, $persona.DisplayName, $persona.PrimarySmtpAddress, $persona.ExternalDirectoryObjectId) +
            @(@($persona.EmailAddresses) | Where-Object { "$_" -like 'smtp:*' } | ForEach-Object { "$_".Substring(5) }) | Where-Object { $_ }
    }

    foreach ($b in @(Get-EXOMailbox -RecipientTypeDetails $tipos -ResultSize Unlimited -Properties GrantSendOnBehalfTo -ErrorAction Stop)) {
        try {
            $delegados = @(
                Get-EXOMailboxPermission -Identity $b.UserPrincipalName -ResultSize Unlimited -ErrorAction Stop |
                    Where-Object { "$($_.AccessRights)" -match 'FullAccess|ChangePermission|ChangeOwner' -and $_.IsInherited -ne $true -and $_.Deny -ne $true -and "$($_.User)" -notlike 'NT AUTHORITY\*' } |
                    ForEach-Object { [pscustomobject]@{ Delegado = "$($_.User)"; Permiso = ([regex]::Matches("$($_.AccessRights)", 'FullAccess|ChangePermission|ChangeOwner').Value -join ', ') } }
                Get-EXORecipientPermission -Identity $b.UserPrincipalName -ResultSize Unlimited -ErrorAction Stop |
                    Where-Object { "$($_.AccessRights)" -match 'SendAs' -and $_.AccessControlType -ne 'Deny' -and $_.IsInherited -ne $true -and "$($_.Trustee)" -notlike 'NT AUTHORITY\*' } |
                    ForEach-Object { [pscustomobject]@{ Delegado = "$($_.Trustee)"; Permiso = 'SendAs' } }
                @($b.GrantSendOnBehalfTo) | Where-Object { $_ } | ForEach-Object { [pscustomobject]@{ Delegado = "$_"; Permiso = 'SendOnBehalf' } }
            )
        } catch {
            $fallas.Add("$($b.PrimarySmtpAddress)")
            $filas.Add([pscustomobject]@{ Buzon = $b.DisplayName; Correo = $b.PrimarySmtpAddress; TipoBuzon = $b.RecipientTypeDetails; Delegado = ''; Permiso = "No se pudo consultar: $_" })
            continue
        }
        foreach ($d in $delegados) {
            if ($Usuario -and $d.Delegado -notin $identidades) { continue }
            $filas.Add([pscustomobject]@{ Buzon = $b.DisplayName; Correo = $b.PrimarySmtpAddress; TipoBuzon = $b.RecipientTypeDetails; Delegado = $d.Delegado; Permiso = $d.Permiso })
        }
    }
} finally {
    Disconnect-ExchangeOnline -Confirm:$false
}

$salida = @($filas | Sort-Object Correo, Permiso, Delegado)
Write-Host "Permisos en el reporte: $($salida.Count)"
if ($RutaCsv) {
    if ($salida.Count) {
        # UTF-8 con BOM para que Excel en español muestre bien los acentos.
        $salida | ForEach-Object { Limpiar-Fila $_ } | Export-Csv -LiteralPath $RutaCsv -NoTypeInformation -Encoding utf8BOM
    }
    Write-Host "CSV guardado en $RutaCsv"
} else {
    $salida | Format-Table -AutoSize
}

if ($fallas.Count) { throw "No se pudieron consultar $($fallas.Count) buzones: $($fallas -join ', '). Revisa las filas marcadas en el reporte." }

Parte del playbook

Relacionados