Public/Remove-RBACforAppEntry.ps1
|
<# .SYNOPSIS Safely removes the Exchange Online RBAC scoping that New-RBACforAppEntry creates for an Entra application. .DESCRIPTION Remove-RBACforAppEntry is the teardown counterpart to New-RBACforAppEntry. It resolves an Entra application / service principal (by display name, AppId, or service principal object id), derives the scoped Unified Group name the same way New-RBACforAppEntry does ("{GroupPrefix}-{DisplayName}", sanitized via Get-SafeName), and removes: 1. the Exchange Online management role assignments scoped to that group that belong to this application, and 2. the scoped Unified Group itself. Before removing anything the function runs two safety checks and ABORTS (removing nothing) if either fails: * Foreign role assignments - if any management role assignment scoped to the group resolves to a DIFFERENT identity than this service principal, the group is still in use and removal is refused. * Real members - if the group has any member other than the bootstrap placeholder (-BootstrapMember, default 'GraphAPI-Dummy'), the group is still in use and removal is refused. The Exchange Online service principal pointer ("{DisplayName}_SP") is intentionally left in place because it may be shared by other scoping. There is no -Role parameter: the safety question is about the group as a whole, so the teardown operates at group granularity and removes all of this app's assignments scoped to the group or nothing at all. The function supports -WhatIf and -Confirm through SupportsShouldProcess; under -WhatIf no removals are performed and IsRemoved is reported as false. .PARAMETER RegisteredAppName Display name of the registered application or service principal. Default parameter set; must resolve to exactly one service principal. .PARAMETER AppId Application (client) id of the registered application. GUID-validated. .PARAMETER SpObjectId Object id of the target service principal. GUID-validated. .PARAMETER GroupPrefix Prefix used when building the Unified Group name. Defaults to 'Um365RAo1' (matching New-RBACforAppEntry). .PARAMETER BootstrapMember Bootstrap placeholder member to ignore when deciding whether the group has real members. Defaults to 'GraphAPI-Dummy' (matching New-RBACforAppEntry). .EXAMPLE Remove-RBACforAppEntry -RegisteredAppName 'Contoso Mail App' -WhatIf -Verbose Shows the role assignments and Unified Group that would be removed for the resolved application, without making changes. .EXAMPLE Remove-RBACforAppEntry -AppId '11111111-2222-3333-4444-555555555555' Removes this application's role assignments and the scoped Unified Group, but only if no foreign assignments and no real members are present. .OUTPUTS PSCustomObject A summary object with the resolved identity, the Unified Group name and whether it existed, the assignments scoped to the group partitioned into own/foreign, the real members found, what was removed (AssignmentsRemoved, GroupRemoved), an overall IsRemoved flag, a Reason when removal was refused or skipped, and any Warnings/Errors. .NOTES Requires a connected Microsoft Graph session (Get-MgServicePrincipal, Get-MgContext) and a connected Exchange Online session (Get-UnifiedGroup, Get-UnifiedGroupLinks, Get-ManagementRoleAssignment, Remove-ManagementRoleAssignment, Remove-UnifiedGroup). Inverse of New-RBACforAppEntry; the safe companion to Test-RBACforAppEntry. #> function Remove-RBACforAppEntry { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')] [OutputType([pscustomobject])] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'ByName')] [Alias('DisplayName','Name')] [ValidateNotNullOrEmpty()] [string] $RegisteredAppName, [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ByAppId')] [Alias('ClientId','ApplicationId')] [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}$')] [string] $AppId, [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'BySpObjectId')] [Alias('Id','ObjectId','ServicePrincipalId')] [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}$')] [string] $SpObjectId, [Parameter()] [ValidateNotNullOrEmpty()] [string] $GroupPrefix = 'Um365RAo1', [Parameter()] [ValidateNotNullOrEmpty()] [string] $BootstrapMember = 'GraphAPI-Dummy' ) begin { $tenantid = $null try { $tenantid = Get-MgContext | Select-Object -ExpandProperty TenantId } catch { Write-Verbose -Message "Could not read tenant id from Get-MgContext: $($_.Exception.Message)" } } process { $result = [ordered]@{ ParameterSet = $PSCmdlet.ParameterSetName IdentityInput = $RegisteredAppName ResolvedDisplay = $null AppId = $null SpObjectId = $null TenantId = $tenantid UnifiedGroupName = $null UnifiedGroupExisted = $false AssignmentsScoped = @() OwnAssignments = @() ForeignAssignments = @() RealMembers = @() AssignmentsRemoved = @() GroupRemoved = $false IsRemoved = $false Reason = $null Warnings = @() Errors = @() } try { # --- Resolve the service principal depending on parameter set. $sp = $null switch ($PSCmdlet.ParameterSetName) { 'BySpObjectId' { $result.IdentityInput = $SpObjectId $sp = Get-MgServicePrincipal -ServicePrincipalId $SpObjectId -ErrorAction Stop } 'ByAppId' { $result.IdentityInput = $AppId $matchesRes = @(Get-MgServicePrincipal -Filter "appId eq `'$AppId`'" -ErrorAction Stop) if ($matchesRes.Count -eq 0) { throw "No service principal found for AppId '$AppId'." } if ($matchesRes.Count -gt 1) { throw "Unexpected: multiple service principals for AppId '$AppId'." } $sp = $matchesRes[0] } 'ByName' { $matchesRes = @(Get-MgServicePrincipal -Filter "displayName eq `'$RegisteredAppName`'" -ErrorAction Stop) if ($matchesRes.Count -eq 0) { throw "No service principal found for displayName '$RegisteredAppName'." } if ($matchesRes.Count -gt 1) { $ids = ($matchesRes | Select-Object -First 10 -ExpandProperty Id) -join ', ' throw "Ambiguous displayName '$RegisteredAppName' matched $($matchesRes.Count) service principals. Re-run with -AppId or -SpObjectId. Example SP objectIds: $ids" } $sp = $matchesRes[0] } } $result.ResolvedDisplay = $sp.DisplayName $result.AppId = $sp.AppId $result.SpObjectId = $sp.Id # --- Unified Group name (same rule as New-RBACforAppEntry). $umGroupName = Get-SafeName -s ("{0}-{1}" -f $GroupPrefix, $sp.DisplayName) $result.UnifiedGroupName = $umGroupName $group = Get-UnifiedGroup -Identity $umGroupName -ErrorAction SilentlyContinue $result.UnifiedGroupExisted = [bool]$group # --- Assignments scoped to the group (client-side filter: no -App on the EXO cmdlet). $scoped = @(Get-ManagementRoleAssignment -ErrorAction SilentlyContinue) | Where-Object { $_ -and ([string]$_.RecipientWriteScope -in @('Group','CustomRecipientScope')) -and ([string]$_.CustomRecipientWriteScope -eq $umGroupName) } $result.AssignmentsScoped = @($scoped | ForEach-Object { [string]$_.Name }) # --- Partition own vs foreign by assignee (same needle set as Get-RBACforAppEntry). $needles = @($sp.DisplayName, ("{0}_SP" -f $sp.DisplayName), $sp.AppId, $sp.Id) | Where-Object { $_ } $own = @() $foreign = @() foreach ($a in $scoped) { $assignee = [string]$a.RoleAssigneeName $isOwn = $false foreach ($n in $needles) { if ($assignee -and $assignee -like "*$n*") { $isOwn = $true; break } } if ($isOwn) { $own += $a } else { $foreign += $a } } $result.OwnAssignments = @($own | ForEach-Object { [string]$_.Name }) $result.ForeignAssignments = @($foreign | ForEach-Object { [string]$_.Name }) # --- Real members (ignore the bootstrap placeholder). $realMembers = @() if ($group) { $links = @(Get-UnifiedGroupLinks -Identity $umGroupName -LinkType Members -ErrorAction SilentlyContinue) foreach ($l in $links) { if (-not $l) { continue } $smtp = [string]$l.PrimarySmtpAddress $name = [string]$l.Name if (($smtp -and $smtp -eq $BootstrapMember) -or ($name -and $name -eq $BootstrapMember)) { continue } $realMembers += if ($smtp) { $smtp } else { $name } } } $result.RealMembers = @($realMembers) # --- Safety gate: abort (remove nothing) when the group is still in use. if ($foreign.Count -gt 0 -or $realMembers.Count -gt 0) { $parts = @() if ($foreign.Count -gt 0) { $parts += "$($foreign.Count) foreign role assignment(s) scoped to the group" } if ($realMembers.Count -gt 0) { $parts += "$($realMembers.Count) member(s) beyond the '$BootstrapMember' placeholder" } $result.Reason = "Refusing to remove '$umGroupName': it is still in use ($($parts -join '; '))." $result.IsRemoved = $false Write-Warning -Message $result.Reason return [pscustomobject]$result } # --- Safe path: remove this app's assignments, then the group. $removedAny = $false foreach ($a in $own) { $name = [string]$a.Name if ($PSCmdlet.ShouldProcess($name, 'Remove-ManagementRoleAssignment')) { try { Remove-ManagementRoleAssignment -Identity $name -Confirm:$false -ErrorAction Stop $result.AssignmentsRemoved += $name $removedAny = $true } catch { $result.Errors += "Failed to remove role assignment '$name': $($_.Exception.Message)" } } } if (-not $group) { $result.Warnings += "Unified Group '$umGroupName' did not exist; only role assignments (if any) were processed." } elseif ($PSCmdlet.ShouldProcess($umGroupName, 'Remove-UnifiedGroup')) { try { Remove-UnifiedGroup -Identity $umGroupName -Confirm:$false -ErrorAction Stop $result.GroupRemoved = $true $removedAny = $true } catch { $result.Errors += "Failed to remove Unified Group '$umGroupName': $($_.Exception.Message)" } } # IsRemoved is true only when the group is gone (or never existed) and no errors occurred. $result.IsRemoved = ($result.Errors.Count -eq 0) -and ($result.GroupRemoved -or -not $group) -and $removedAny if (-not $result.IsRemoved -and -not $result.Reason -and $WhatIfPreference) { $result.Reason = 'WhatIf: no changes were made.' } [pscustomobject]$result } catch { $result.Errors += $_.Exception.Message [pscustomobject]$result } } } |