Public/Set-RBACforAppEntry.ps1
|
<# .SYNOPSIS Reconciles a registered application's Exchange Online RBAC scoping to the desired state: repairs missing components, tops up the Unified Group membership, and can move the role assignments to a different scoping group. .DESCRIPTION Set-RBACforAppEntry is the "make it so" companion to Test-RBACforAppEntry (which only reports) and New-RBACforAppEntry (which creates everything from scratch). It resolves an Entra application / service principal (by display name, AppId, or service principal object id) and brings the components New-RBACforAppEntry provisions into the desired state, changing only what is needed: 1. the scoped Unified Group ("{GroupPrefix}-{DisplayName}", sanitized via Get-SafeName) is created if it is missing (delegated to New-RBACforAppUnifiedGroup), 2. the Exchange Online service principal pointer ("{DisplayName}_SP") is created if it is missing (delegated to Register-EXOServicePrincipal), 3. the requested -Members are added to the group if they are not already members (additive only - existing members are never removed), and 4. one Exchange Online management role assignment exists per requested role, named the same way New-RBACforAppEntry names them ("{ShortRoleToken}-{DisplayName}") and scoped to the target group. A missing assignment is created; an assignment that exists but is scoped to a different group is re-scoped (removed and recreated with the same name) - this is how the scoping group is changed. Changing the scoping group: supply -NewGroupName (an explicit group name) or -NewGroupPrefix (builds "{NewGroupPrefix}-{DisplayName}"). The target group is ensured to exist and the role assignments are re-scoped onto it. The old group is left in place (use Remove-RBACforAppEntry to tear it down once it is no longer in use); members are not migrated. The function supports -WhatIf and -Confirm through SupportsShouldProcess (ConfirmImpact High), so each change is confirmed interactively unless -Confirm:$false / -Force-style suppression is used. Under -WhatIf no changes are made and IsValid reflects the actual (unchanged) state. .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 Role Exchange Online application roles to ensure are assigned. Short names such as Mail.Send are normalized to Application Mail.Send. Defaults to 'Application Mail.Send' (matching New-RBACforAppEntry). .PARAMETER Members Optional recipients to ensure are members of the scoped Unified Group. Each is resolved through Get-Recipient and added if absent. Membership is additive: members already present are left as-is and nothing is removed. .PARAMETER ManagedBy Recipient assigned as the Unified Group owner when the group must be created. Defaults to 'GraphAPI-Dummy-owner' (matching New-RBACforAppEntry). .PARAMETER GroupPrefix Prefix used when building the current Unified Group name. Defaults to 'Um365RAo1' (matching New-RBACforAppEntry). .PARAMETER BootstrapMember Optional initial member passed to New-RBACforAppUnifiedGroup when the group must be created. Defaults to 'GraphAPI-Dummy'. .PARAMETER NewGroupPrefix Optional. Switch the role assignments to a group named "{NewGroupPrefix}-{DisplayName}" (sanitized). Ignored when -NewGroupName is supplied. .PARAMETER NewGroupName Optional. Switch the role assignments to this explicit group name (sanitized via Get-SafeName). Takes precedence over -NewGroupPrefix. .EXAMPLE Set-RBACforAppEntry -RegisteredAppName 'Contoso Mail App' -WhatIf -Verbose Shows which missing components would be (re)created for the default Application Mail.Send setup, without making changes. .EXAMPLE Set-RBACforAppEntry -RegisteredAppName 'Contoso Mail App' -Members 'shared@contoso.com' Ensures every component exists and adds shared@contoso.com to the scoped group if it is not already a member. .EXAMPLE Set-RBACforAppEntry -AppId '11111111-2222-3333-4444-555555555555' -NewGroupPrefix 'Um365Prod' Re-scopes the application's role assignments onto the 'Um365Prod-...' group (creating it if needed). .OUTPUTS PSCustomObject A summary object with the resolved identity, the current and target group names, a GroupChanged flag, which components were created, the requested/added members, and the role assignments partitioned into created / re-scoped / unchanged, an overall IsValid flag, 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-ServicePrincipal, Get-Recipient, Get-ManagementRoleAssignment, New-ManagementRoleAssignment, Remove-ManagementRoleAssignment, plus the cmdlets used by the delegated New-RBACforAppUnifiedGroup / Register-EXOServicePrincipal). Reconcile companion to Test-RBACforAppEntry and New-RBACforAppEntry. #> function Set-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(Position = 1)] [ValidateNotNullOrEmpty()] [string[]] $Role = @('Application Mail.Send'), [Parameter(Position = 2)] [string[]] $Members, [Parameter()] [ValidateNotNullOrEmpty()] [string] $ManagedBy = 'GraphAPI-Dummy-owner', [Parameter()] [ValidateNotNullOrEmpty()] [string] $GroupPrefix = 'Um365RAo1', [Parameter()] [string] $BootstrapMember = 'GraphAPI-Dummy', [Parameter()] [ValidateNotNullOrEmpty()] [string] $NewGroupPrefix, [Parameter()] [ValidateNotNullOrEmpty()] [string] $NewGroupName ) begin { $shortRoleMap = Get-AppRoleMap $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 CurrentGroupName = $null TargetGroupName = $null GroupChanged = $false UnifiedGroupExisted = $false UnifiedGroupCreated = $false ExoServicePrincipalName = $null ExoServicePrincipalExisted = $false ExoServicePrincipalCreated = $false MembersRequested = @() MembersAdded = @() MembersAlreadyPresent = @() FilteredMembers = @() RolesNormalized = @() RoleAssignmentsCreated = @() RoleAssignmentsRescoped = @() RoleAssignmentsUnchanged = @() IsValid = $false 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 # --- Determine the current and target Unified Group names (same name rule as New-RBACforAppEntry). $currentGroup = Get-SafeName -s ("{0}-{1}" -f $GroupPrefix, $sp.DisplayName) if ($PSBoundParameters.ContainsKey('NewGroupName')) { $targetGroup = Get-SafeName -s $NewGroupName } elseif ($PSBoundParameters.ContainsKey('NewGroupPrefix')) { $targetGroup = Get-SafeName -s ("{0}-{1}" -f $NewGroupPrefix, $sp.DisplayName) } else { $targetGroup = $currentGroup } $result.CurrentGroupName = $currentGroup $result.TargetGroupName = $targetGroup $result.GroupChanged = ($targetGroup -ne $currentGroup) # --- Ensure the target Unified Group exists (delegated to New-RBACforAppUnifiedGroup). $group = Get-UnifiedGroup -Identity $targetGroup -ErrorAction SilentlyContinue $result.UnifiedGroupExisted = [bool]$group if (-not $group) { if ($PSCmdlet.ShouldProcess($targetGroup, 'Create Unified Group')) { $ugResult = New-RBACforAppUnifiedGroup -Name $targetGroup -ManagedBy $ManagedBy -BootstrapMember $BootstrapMember -WarningVariable ugWarnings foreach ($w in $ugWarnings) { if ([string]$w.Message -like '*already exists*') { $result.Warnings += [string]$w.Message } } if ($ugResult) { $result.UnifiedGroupCreated = $true $group = $ugResult.Group } } } # --- Ensure the Exchange Online service principal pointer (matched by AppId, then name). $exoSpDisplay = "{0}_SP" -f $sp.DisplayName $result.ExoServicePrincipalName = $exoSpDisplay $exoSp = @(Get-ServicePrincipal -ErrorAction SilentlyContinue) | Where-Object { $_ -and (($_.AppId -eq $sp.AppId) -or ($_.DisplayName -eq $exoSpDisplay)) } | Select-Object -First 1 if ($exoSp) { $result.ExoServicePrincipalExisted = $true } elseif ($PSCmdlet.ShouldProcess($exoSpDisplay, 'Create Exchange Online service principal')) { $null = Register-EXOServicePrincipal -AppId $sp.AppId -ObjectId $sp.Id -DisplayName $exoSpDisplay $result.ExoServicePrincipalCreated = $true } # --- Members (additive): add any requested member not already in the target group. if ($PSBoundParameters.ContainsKey('Members')) { $requested = @($Members | Where-Object { $_ }) $result.MembersRequested = $requested $currentUserUpn = $null try { $currentUserUpn = (Get-MgContext -ErrorAction Stop).Account } catch { $result.Warnings += "Could not retrieve current connection user via Get-MgContext; connection user filtering will be skipped. Error: $($_.Exception.Message)" } $links = @(Get-UnifiedGroupLinks -Identity $targetGroup -LinkType Members -ErrorAction SilentlyContinue) $linkAddresses = @($links | ForEach-Object { [string]$_.PrimarySmtpAddress; [string]$_.Name } | Where-Object { $_ }) foreach ($member in $requested) { if ($currentUserUpn -and ($member -ieq $currentUserUpn)) { $result.FilteredMembers += [string]$member $filterWarning = "Current connection user '$currentUserUpn' was found in the members list and has been filtered out." $result.Warnings += $filterWarning Write-Warning -Message $filterWarning continue } $rec = Get-Recipient -Identity $member -ErrorAction SilentlyContinue if (-not $rec) { $result.Warnings += "Recipient not found for '$member' (skipped)." continue } $needle = [string]$rec.PrimarySmtpAddress if (($linkAddresses -contains $needle) -or ($linkAddresses -contains [string]$rec.Name)) { $result.MembersAlreadyPresent += $needle continue } if ($PSCmdlet.ShouldProcess("UnifiedGroup $targetGroup", "Add member $needle")) { Add-UnifiedGroupLinks -Identity $targetGroup -LinkType Members -Links $needle -ErrorAction Stop $result.MembersAdded += $needle } } } # --- Role assignments: ensure one per role, scoped to the target group. $rolesNormalized = foreach ($r in @($Role)) { Get-NormalizeRole $r } $result.RolesNormalized = @($rolesNormalized) $rolesAllSatisfied = $true foreach ($roleItem in $rolesNormalized) { $shortRoleName = $shortRoleMap[$roleItem] if (-not $shortRoleName) { $result.Warnings += "Role '$roleItem' is not a recognized application role; skipping its assignment." $rolesAllSatisfied = $false continue } $rbacName = Get-SafeName -s ("{0}-{1}" -f $shortRoleName, $sp.DisplayName) -max 63 try { $existing = Get-ManagementRoleAssignment -Identity $rbacName -ErrorAction SilentlyContinue $scopedToTarget = $existing -and ([string]$existing.RecipientWriteScope -in @('Group','CustomRecipientScope')) -and ([string]$existing.CustomRecipientWriteScope -eq $targetGroup) if ($existing -and $scopedToTarget) { # Already in desired state. $result.RoleAssignmentsUnchanged += $rbacName } elseif ($existing) { # Exists but scoped elsewhere (repair / group change): re-scope by recreating. $action = "Re-scope role '$roleItem' for App '$($sp.DisplayName)' to '$targetGroup'" if ($PSCmdlet.ShouldProcess($rbacName, $action)) { Remove-ManagementRoleAssignment -Identity $rbacName -Confirm:$false -ErrorAction Stop $null = New-ManagementRoleAssignment -App $sp.Id -Role $roleItem -RecipientGroupScope $targetGroup -Name $rbacName -ErrorAction Stop $result.RoleAssignmentsRescoped += $rbacName } else { $rolesAllSatisfied = $false } } else { # Missing: create. $action = "Assign '$roleItem' to App '$($sp.DisplayName)' scoped to '$targetGroup'" if ($PSCmdlet.ShouldProcess($rbacName, $action)) { $null = New-ManagementRoleAssignment -App $sp.Id -Role $roleItem -RecipientGroupScope $targetGroup -Name $rbacName -ErrorAction Stop $result.RoleAssignmentsCreated += $rbacName } else { $rolesAllSatisfied = $false } } } catch { $result.Errors += "Failed to reconcile role assignment '$rbacName' ($roleItem): $($_.Exception.Message)" $rolesAllSatisfied = $false } } # IsValid: every component is present after this run (true unchanged state, or actually applied). $groupOk = $result.UnifiedGroupExisted -or $result.UnifiedGroupCreated $exoOk = $result.ExoServicePrincipalExisted -or $result.ExoServicePrincipalCreated $result.IsValid = ($result.Errors.Count -eq 0) -and $groupOk -and $exoOk -and $rolesAllSatisfied [pscustomobject]$result } catch { $result.Errors += $_.Exception.Message [pscustomobject]$result } } } |