Public/Test-RBACforAppEntry.ps1
|
<# .SYNOPSIS Validates that a registered application has all the Exchange Online RBAC components that New-RBACforAppEntry creates. .DESCRIPTION Test-RBACforAppEntry resolves an Entra application / service principal (by display name, AppId, or service principal object id) and checks that every component New-RBACforAppEntry provisions is in place: 1. the Entra service principal is resolvable (Microsoft Graph), 2. the scoped Unified Group "{GroupPrefix}-{DisplayName}" (sanitized via the same Get-SafeName rule) exists, 3. the Exchange Online service principal pointer "{DisplayName}_SP" exists, and 4. one Exchange Online management role assignment exists per requested role, named the same way New-RBACforAppEntry names them ("{ShortRoleToken}-{DisplayName}") and bound to the expected role. When -Members is supplied, the requested recipients are also verified against the Unified Group's membership. The function is read-only: it makes no changes and does not support -WhatIf. The defaults for -Role and -GroupPrefix mirror New-RBACforAppEntry, so a plain "Test-RBACforAppEntry -RegisteredAppName <app>" validates the default creation. .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 expected to be 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 expected to be members of the Unified Group scope. When supplied, each is resolved through Get-Recipient and checked against the group's membership. Omit to skip the membership check. .PARAMETER GroupPrefix Prefix used when building the Unified Group name. Defaults to 'Um365RAo1' (matching New-RBACforAppEntry). .EXAMPLE Test-RBACforAppEntry -RegisteredAppName 'Contoso Mail App' Validates the default Application Mail.Send setup for the resolved application and returns a summary with an IsValid flag. .EXAMPLE Test-RBACforAppEntry -AppId '11111111-2222-3333-4444-555555555555' -Role 'Mail.Send','Calendars.Read' -Members 'shared@contoso.com' Checks the service principal, Unified Group, Exchange Online service principal, both role assignments, and that shared@contoso.com is a group member. .EXAMPLE New-RBACforAppEntry -RegisteredAppName 'Contoso' -WhatIf; Test-RBACforAppEntry -RegisteredAppName 'Contoso' Reports which components are still missing before/after a run. .OUTPUTS PSCustomObject A summary object with the resolved identity, per-component existence flags (ServicePrincipalExists, UnifiedGroupExists, ExoServicePrincipalExists), the expected/found/missing role assignments, optional membership results, an overall IsValid flag, a Missing list, and any Warnings/Errors. .NOTES Requires a connected Microsoft Graph session (Get-MgServicePrincipal, Get-MgContext) and a connected Exchange Online session (Get-UnifiedGroup, Get-ServicePrincipal, Get-ManagementRoleAssignment, and, when -Members is supplied, Get-Recipient and Get-UnifiedGroupLinks). Read-only companion to New-RBACforAppEntry. #> function Test-RBACforAppEntry { [CmdletBinding(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] $GroupPrefix = 'Um365RAo1' ) 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 ServicePrincipalExists = $false UnifiedGroupName = $null UnifiedGroupExists = $false ExoServicePrincipalName = $null ExoServicePrincipalExists = $false RolesExpected = @() RoleAssignmentsExpected = @() RoleAssignmentsFound = @() RoleAssignmentsMissing = @() MembersExpected = @() MembersPresent = @() MembersMissing = @() IsValid = $false Missing = @() 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.ServicePrincipalExists = $true $result.ResolvedDisplay = $sp.DisplayName $result.AppId = $sp.AppId $result.SpObjectId = $sp.Id # --- Unified Group existence (same name rule as New-RBACforAppEntry). $umGroupName = Get-SafeName -s ("{0}-{1}" -f $GroupPrefix, $sp.DisplayName) $result.UnifiedGroupName = $umGroupName $group = Get-UnifiedGroup -Identity $umGroupName -ErrorAction SilentlyContinue if ($group) { $result.UnifiedGroupExists = $true } else { $result.Missing += "Unified Group '$umGroupName'" } # --- Exchange Online service principal pointer existence (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.ExoServicePrincipalExists = $true } else { $result.Missing += "Exchange Online service principal '$exoSpDisplay'" } # --- Role assignment existence, by the deterministic name New-RBACforAppEntry builds. $rolesNormalized = foreach ($r in @($Role)) { Get-NormalizeRole $r } $result.RolesExpected = @($rolesNormalized) foreach ($roleItem in $rolesNormalized) { $shortRoleName = $shortRoleMap[$roleItem] if (-not $shortRoleName) { $result.Warnings += "Role '$roleItem' is not a recognized application role; skipping its assignment check." continue } $expectedName = Get-SafeName -s ("{0}-{1}" -f $shortRoleName, $sp.DisplayName) -max 63 $result.RoleAssignmentsExpected += $expectedName $assignment = Get-ManagementRoleAssignment -Identity $expectedName -ErrorAction SilentlyContinue if ($assignment -and ([string]$assignment.Role -eq $roleItem)) { $result.RoleAssignmentsFound += $expectedName } else { $result.RoleAssignmentsMissing += $expectedName $result.Missing += "Role assignment '$expectedName' ($roleItem)" } } # --- Optional membership check. if ($PSBoundParameters.ContainsKey('Members')) { $requested = @($Members | Where-Object { $_ }) $result.MembersExpected = $requested if ($result.UnifiedGroupExists) { $links = @(Get-UnifiedGroupLinks -Identity $umGroupName -LinkType Members -ErrorAction SilentlyContinue) $linkAddresses = @($links | ForEach-Object { [string]$_.PrimarySmtpAddress; [string]$_.Name } | Where-Object { $_ }) foreach ($member in $requested) { $rec = Get-Recipient -Identity $member -ErrorAction SilentlyContinue $needle = if ($rec) { [string]$rec.PrimarySmtpAddress } else { [string]$member } if ($linkAddresses -contains $needle -or ($rec -and ($linkAddresses -contains [string]$rec.Name))) { $result.MembersPresent += $needle } else { $result.MembersMissing += $needle $result.Missing += "Group member '$needle'" } } } else { foreach ($member in $requested) { $result.MembersMissing += [string]$member $result.Missing += "Group member '$member' (group missing)" } } } $result.IsValid = $result.ServicePrincipalExists -and $result.UnifiedGroupExists -and $result.ExoServicePrincipalExists -and ($result.RoleAssignmentsMissing.Count -eq 0) -and ($result.MembersMissing.Count -eq 0) [pscustomobject]$result } catch { $result.Errors += $_.Exception.Message [pscustomobject]$result } } } |