Public/Get-TntSharedMailboxComplianceReport.ps1
|
function Get-TntSharedMailboxComplianceReport { <# .SYNOPSIS Reports on shared mailbox licensing compliance. .DESCRIPTION Retrieves all shared mailboxes and checks whether enabled accounts have an Exchange Online license assigned. Shared mailboxes with sign-in enabled but no Exchange Online license are flagged as noncompliant. .PARAMETER TenantId The Azure AD Tenant ID (GUID) to connect to. .PARAMETER ClientId The Application (Client) ID of the app registration created for security reporting. .PARAMETER ClientSecret The client secret for the app registration. Use this for automated scenarios. .PARAMETER CertificateThumbprint The thumbprint of the certificate to use for authentication instead of client secret. .EXAMPLE Get-TntSharedMailboxComplianceReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret Checks all shared mailboxes for licensing compliance. .OUTPUTS System.Management.Automation.PSCustomObject Returns a structured object containing: - Summary: Total shared mailboxes, compliant/noncompliant counts - Mailboxes: Detailed per-mailbox compliance status .NOTES Author: Tom de Leeuw Website: https://systom.dev Module: TenantReports Required Permissions: - User.Read.All (Application) - Exchange Online app access .LINK https://systom.dev #> [CmdletBinding(DefaultParameterSetName = 'ClientSecret')] [OutputType([System.Management.Automation.PSCustomObject])] param( [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [Alias('Tenant')] [string]$TenantId, [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'ClientSecret')] [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')] [Alias('ApplicationId')] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Secret', 'ApplicationSecret')] [SecureString]$ClientSecret, [Parameter(Mandatory = $true, ParameterSetName = 'Certificate', ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [Alias('Thumbprint')] [string]$CertificateThumbprint, [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive ) begin { Write-Information 'STARTED : Shared mailbox compliance analysis...' -InformationAction Continue } process { try { $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters $ConnectionInfo = Connect-TntGraphSession @ConnectionParams # Connect to Exchange Online (required - throw on failure) try { if ($PSCmdlet.ParameterSetName -eq 'ClientSecret') { $TokenParams = @{ TenantId = $TenantId ClientId = $ClientId ClientSecret = $ClientSecret Scope = 'Exchange' } $ExchangeToken = Get-GraphToken @TokenParams Connect-ExchangeOnline -Organization $TenantId -AccessToken $ExchangeToken.AccessToken -ShowBanner:$false -ErrorAction Stop } else { # Certificate auth requires domain name, not GUID $TenantDomain = $null try { $Org = Get-MgOrganization -Property VerifiedDomains | Select-Object -First 1 if ($Org.VerifiedDomains) { $TenantDomain = ($Org.VerifiedDomains.Where({ $_.IsInitial }) | Select-Object -First 1 -ExpandProperty Name) if (-not $TenantDomain) { $TenantDomain = ($Org.VerifiedDomains.Where({ $_.IsDefault }) | Select-Object -First 1 -ExpandProperty Name) } } } catch { Write-Verbose "Could not resolve tenant domain: $($_.Exception.Message)" } if (-not $TenantDomain) { $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( [System.Exception]::new('Could not resolve tenant domain name. Certificate authentication requires a domain name for Exchange Online, not a tenant GUID.'), 'ExchangeTenantDomainResolutionError', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $TenantId )) } Connect-ExchangeOnline -AppId $ClientId -CertificateThumbprint $CertificateThumbprint -Organization $TenantDomain -ShowBanner:$false -ErrorAction Stop } Write-Verbose 'Successfully connected to Exchange Online.' } catch { $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Exchange Online connection required: $($_.Exception.Message)"), 'ExchangeConnectionError', [System.Management.Automation.ErrorCategory]::ConnectionError, $null )) } $Mailboxes = [System.Collections.Generic.List[PSCustomObject]]::new() # Exchange Online service plan IDs $ExchangePlans = @( 'EXCHANGE_S_ENTERPRISE' 'EXCHANGE_S_STANDARD' 'EXCHANGE_S_FOUNDATION' 'EXCHANGE_S_DESKLESS' 'EXCHANGE_S_ARCHIVE' ) # Get all shared mailboxes Write-Verbose 'Retrieving shared mailboxes...' $SharedMailboxes = Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited -Properties UserPrincipalName, DisplayName, ExternalDirectoryObjectId Write-Verbose "Found $($SharedMailboxes.Count) shared mailboxes. Checking compliance..." # Separate mailboxes without an ExternalDirectoryObjectId (cannot query Graph) $MailboxesWithId = [System.Collections.Generic.List[object]]::new() foreach ($Mbx in $SharedMailboxes) { if (-not $Mbx.ExternalDirectoryObjectId) { $Mailboxes.Add([PSCustomObject]@{ DisplayName = $Mbx.DisplayName UserPrincipalName = $Mbx.UserPrincipalName AccountEnabled = 'Unknown' HasExchangeLicense = $false ComplianceStatus = 'Unknown' LicensePlans = @() }) } else { $MailboxesWithId.Add($Mbx) } } # Process mailboxes with IDs in batches of 10 (20 requests per batch: user + licenseDetails) $BatchSize = 10 for ($i = 0; $i -lt $MailboxesWithId.Count; $i += $BatchSize) { $End = [Math]::Min($i + $BatchSize, $MailboxesWithId.Count) $Chunk = $MailboxesWithId.GetRange($i, $End - $i) # Build batch request body $Requests = [System.Collections.Generic.List[hashtable]]::new() foreach ($Mbx in $Chunk) { $Guid = $Mbx.ExternalDirectoryObjectId $Requests.Add(@{ id = "user-$Guid"; method = 'GET'; url = "/users/$Guid`?`$select=accountEnabled" }) $Requests.Add(@{ id = "license-$Guid"; method = 'GET'; url = "/users/$Guid/licenseDetails" }) } $BatchBody = @{ requests = $Requests.ToArray() } $BatchSuccess = $false try { $BatchResponse = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/v1.0/$batch' -Method POST -Body $BatchBody -ErrorAction Stop $BatchSuccess = $true } catch { Write-Warning "Batch request failed, falling back to individual calls for $($Chunk.Count) mailboxes: $($_.Exception.Message)" } if ($BatchSuccess) { # Index batch responses by id for fast lookup $ResponseMap = @{} foreach ($Resp in $BatchResponse.responses) { $ResponseMap[$Resp.id] = $Resp } foreach ($Mbx in $Chunk) { $Guid = $Mbx.ExternalDirectoryObjectId $AccountEnabled = $null $HasExchangeLicense = $false [System.Collections.Generic.List[string]]$AssignedPlans = @() # Parse user response $UserResp = $ResponseMap["user-$Guid"] if ($UserResp -and $UserResp.status -eq 200) { $AccountEnabled = $UserResp.body.accountEnabled } else { Write-Warning "Could not retrieve user info for $($Mbx.UserPrincipalName): HTTP $($UserResp.status)" } # Parse license response $LicenseResp = $ResponseMap["license-$Guid"] if ($LicenseResp -and $LicenseResp.status -eq 200) { foreach ($License in $LicenseResp.body.value) { $ExchangeServicePlans = @($License.servicePlans).Where({ $_.servicePlanName -in $ExchangePlans -and $_.provisioningStatus -eq 'Success' }) if ($ExchangeServicePlans.Count -gt 0) { $HasExchangeLicense = $true foreach ($Plan in $ExchangeServicePlans) { $AssignedPlans.Add($Plan.servicePlanName) } } } } else { Write-Warning "Could not retrieve license details for $($Mbx.UserPrincipalName): HTTP $($LicenseResp.status)" } # Determine compliance $ComplianceStatus = if ($null -eq $AccountEnabled) { 'Unknown' } elseif (-not $AccountEnabled) { 'Compliant' # Disabled account - no license needed } elseif ($HasExchangeLicense) { 'Compliant' # Enabled with license } else { 'NonCompliant' # Enabled without Exchange license } $Mailboxes.Add([PSCustomObject]@{ DisplayName = $Mbx.DisplayName UserPrincipalName = $Mbx.UserPrincipalName AccountEnabled = $AccountEnabled HasExchangeLicense = $HasExchangeLicense ComplianceStatus = $ComplianceStatus LicensePlans = $AssignedPlans }) } } else { # Fallback: individual calls for this batch foreach ($Mbx in $Chunk) { $UserId = $Mbx.ExternalDirectoryObjectId $AccountEnabled = $null $HasExchangeLicense = $false [System.Collections.Generic.List[string]]$AssignedPlans = @() try { $User = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/users/$UserId`?`$select=accountEnabled" -Method GET -ErrorAction Stop $AccountEnabled = $User.accountEnabled } catch { Write-Warning "Could not retrieve user info for $($Mbx.UserPrincipalName): $($_.Exception.Message)" } try { $LicenseDetails = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/users/$UserId/licenseDetails" -Method GET -ErrorAction Stop foreach ($License in $LicenseDetails.value) { $ExchangeServicePlans = @($License.servicePlans).Where({ $_.servicePlanName -in $ExchangePlans -and $_.provisioningStatus -eq 'Success' }) if ($ExchangeServicePlans.Count -gt 0) { $HasExchangeLicense = $true foreach ($Plan in $ExchangeServicePlans) { $AssignedPlans.Add($Plan.servicePlanName) } } } } catch { Write-Warning "Could not retrieve license details for $($Mbx.UserPrincipalName): $($_.Exception.Message)" } $ComplianceStatus = if ($null -eq $AccountEnabled) { 'Unknown' } elseif (-not $AccountEnabled) { 'Compliant' } elseif ($HasExchangeLicense) { 'Compliant' } else { 'NonCompliant' } $Mailboxes.Add([PSCustomObject]@{ DisplayName = $Mbx.DisplayName UserPrincipalName = $Mbx.UserPrincipalName AccountEnabled = $AccountEnabled HasExchangeLicense = $HasExchangeLicense ComplianceStatus = $ComplianceStatus LicensePlans = $AssignedPlans }) } } } $Compliant = @($Mailboxes.Where({ $_.ComplianceStatus -eq 'Compliant' })) $NonCompliant = @($Mailboxes.Where({ $_.ComplianceStatus -eq 'NonCompliant' })) $Unknown = @($Mailboxes.Where({ $_.ComplianceStatus -eq 'Unknown' })) $Summary = [PSCustomObject]@{ TenantId = $TenantId ReportGeneratedDate = Get-Date TotalSharedMailboxes = $Mailboxes.Count CompliantCount = $Compliant.Count NonCompliantCount = $NonCompliant.Count UnknownCount = $Unknown.Count } Write-Information "FINISHED : Shared mailbox compliance analysis - $($NonCompliant.Count) noncompliant of $($Mailboxes.Count) total." -InformationAction Continue [PSCustomObject][Ordered]@{ Summary = $Summary Mailboxes = $Mailboxes.ToArray() } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-TntSharedMailboxComplianceReport failed: $($_.Exception.Message)", $_.Exception), 'GetTntSharedMailboxComplianceReportError', [System.Management.Automation.ErrorCategory]::OperationStopped, $TenantId ) $PSCmdlet.ThrowTerminatingError($errorRecord) } finally { try { if ($ConnectionInfo.ShouldDisconnect) { Disconnect-TntGraphSession -ConnectionState $ConnectionInfo } } catch { Write-Verbose "Could not disconnect from services: $($_.Exception.Message)" } } } } |