Public/Set-NSPGraphMailSendScope.ps1
|
function Set-NSPGraphMailSendScope { <# .SYNOPSIS Lets an app-only registration send mail as exactly one mailbox, through Exchange RBAC for Applications. Plan-only by default. .DESCRIPTION Graph's Mail.Send application permission granted in Entra reaches every mailbox in the tenant. Exchange RBAC for Applications grants the same right limited to a management scope, and the two are additive: an Entra Mail.Send grant would keep tenant-wide access whatever the scope says. This function therefore refuses to continue (UnscopedEntraGrant) while the app's service principal holds Graph Mail.Send in Entra. Without -Execute it reports what it would do. With -Execute and ShouldProcess approval it creates, in order: - the Exchange service principal pointer for the app; - management scope "NSP Mail.Send <ClientId>", restricted to the mailbox's primary SMTP address (or updates that scope's filter when the mailbox changed); - an "Application Mail.Send" role assignment for the app on that scope. An existing Application Mail.Send assignment on any other scope, or unscoped, is reported as ConflictingAssignment and left alone. Every result that reaches a scope is verified with Test-ServicePrincipalAuthorization, which bypasses Exchange's permission cache; sendMail itself may take up to two hours to honor a new assignment. Needs the active Graph connection (Application.Read.All or wider) and an Exchange Online PowerShell session with rights to manage role assignments. -Connect imports ExchangeOnlineManagement and runs Connect-ExchangeOnline when no session is open. .EXAMPLE Set-NSPGraphMailSendScope -ClientId $record.ClientId -ServicePrincipalId $record.ServicePrincipalId -Mailbox 'scanner@contoso.example' -Connect #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param( [Parameter(Mandatory)][guid]$ClientId, [Parameter(Mandatory)][guid]$ServicePrincipalId, [Parameter(Mandatory)][ValidatePattern('^[^@\s]+@[^@\s]+$')][string]$Mailbox, [switch]$Execute, [switch]$Connect ) $graphAppId = '00000003-0000-0000-c000-000000000000' $roleName = 'Application Mail.Send' $scopeName = "NSP Mail.Send $ClientId" $result = [ordered]@{ Status = $null; ClientId = [string]$ClientId; Mailbox = $Mailbox; PrimarySmtpAddress = $null ScopeName = $scopeName; Actions = @(); Verified = $false; Message = $null } $servicePrincipal = Invoke-NSPGraphRequest -Uri "servicePrincipals/$ServicePrincipalId`?`$select=id,appId,displayName" if ($servicePrincipal['appId'] -ne [string]$ClientId) { throw "Service principal $ServicePrincipalId does not belong to app $ClientId." } $graphSp = @(Invoke-NSPGraphCollection -Uri "servicePrincipals?`$filter=$([uri]::EscapeDataString("appId eq '$graphAppId'"))&`$select=id,appRoles")[0] $mailSendRoleId = @(@($graphSp['appRoles']) | Where-Object { $_ -and $_['value'] -ceq 'Mail.Send' })[0]['id'] $entraGrant = @(Invoke-NSPGraphCollection -Uri "servicePrincipals/$ServicePrincipalId/appRoleAssignments" | Where-Object { $_['resourceId'] -eq $graphSp['id'] -and $_['appRoleId'] -eq $mailSendRoleId }) if ($entraGrant.Count) { $result.Status = 'UnscopedEntraGrant' $result.Message = 'The app holds Graph Mail.Send in Entra, which reaches every mailbox and is not narrowed by an Exchange scope. Remove that admin-consented permission first; nothing was changed.' return [pscustomobject]$result } if ($Connect) { Import-Module ExchangeOnlineManagement -ErrorAction Stop $open = @(Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Connected' }) if (-not $open.Count) { Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop } } if (-not (Get-Command Test-ServicePrincipalAuthorization -ErrorAction SilentlyContinue)) { throw 'No Exchange Online PowerShell session is open. Run Connect-ExchangeOnline, or pass -Connect.' } # Exchange cmdlets throw for a missing object even with -ErrorAction SilentlyContinue. $find = { param([scriptblock]$Lookup) try { & $Lookup } catch { $null } } $recipient = & $find { Get-Recipient -Identity $Mailbox -ErrorAction Stop } if (-not $recipient -or [string]$recipient.RecipientTypeDetails -notin @('UserMailbox', 'SharedMailbox')) { throw "'$Mailbox' is not a user or shared mailbox in this Exchange organization." } $primary = [string]$recipient.PrimarySmtpAddress $result.PrimarySmtpAddress = $primary $filter = "PrimarySmtpAddress -eq '$($primary -replace "'", "''")'" $actions = [System.Collections.Generic.List[string]]::new() $exchangeSp = & $find { Get-ServicePrincipal -Identity ([string]$ClientId) -ErrorAction Stop } if (-not $exchangeSp) { $actions.Add('CreateExchangeServicePrincipal') } $scope = & $find { Get-ManagementScope -Identity $scopeName -ErrorAction Stop } if (-not $scope) { $actions.Add('CreateScope') } elseif ([string]$scope.RecipientFilter -ne $filter) { $actions.Add('UpdateScope') } $assignments = if ($exchangeSp) { @(& $find { Get-ManagementRoleAssignment -RoleAssignee $exchangeSp.Identity -Role $roleName -ErrorAction Stop }) | Where-Object { $_ } } else { @() } $other = @($assignments | Where-Object { [string]$_.CustomResourceScope -ne $scopeName }) if ($other.Count) { $result.Status = 'ConflictingAssignment' $result.Message = "The app already has $roleName on another or no scope: $(@($other | ForEach-Object { if ($_.CustomResourceScope) { $_.CustomResourceScope } else { '(unscoped)' } }) -join ', '). Review it in Exchange; nothing was changed." return [pscustomobject]$result } if (-not @($assignments).Count) { $actions.Add('CreateRoleAssignment') } $result.Actions = @($actions) if ($actions.Count -and -not $Execute) { $result.Status = 'PlanOnly' $result.Message = "Run again with -Execute to apply: $($actions -join ', ')." return [pscustomobject]$result } if ($actions.Count) { if (-not $PSCmdlet.ShouldProcess("app $ClientId", "Allow $roleName as $primary only: $($actions -join ', ')")) { return } if ('CreateExchangeServicePrincipal' -in $actions) { New-ServicePrincipal -AppId ([string]$ClientId) -ObjectId ([string]$ServicePrincipalId) -DisplayName ([string]$servicePrincipal['displayName']) -ErrorAction Stop | Out-Null } if ('CreateScope' -in $actions) { New-ManagementScope -Name $scopeName -RecipientRestrictionFilter $filter -ErrorAction Stop | Out-Null } if ('UpdateScope' -in $actions) { Set-ManagementScope -Identity $scopeName -RecipientRestrictionFilter $filter -ErrorAction Stop } if ('CreateRoleAssignment' -in $actions) { New-ManagementRoleAssignment -App ([string]$ClientId) -Role $roleName -CustomResourceScope $scopeName -ErrorAction Stop | Out-Null } } $check = @(Test-ServicePrincipalAuthorization -Identity ([string]$ClientId) -Resource $primary -ErrorAction Stop | Where-Object { [string]$_.RoleName -eq $roleName }) $result.Verified = [bool](@($check | Where-Object { $_.InScope -eq $true }).Count) $result.Status = if (-not $actions.Count) { 'AlreadyScoped' } else { 'Scoped' } $result.Message = if ($result.Verified) { "Exchange confirms $roleName is in scope for $primary only. sendMail may take up to two hours to honor a new assignment." } else { "Exchange did not confirm $roleName in scope for $primary. Re-check with Test-ServicePrincipalAuthorization before relying on it." } [pscustomobject]$result } |