Public/Grant-UTCMWorkloadAccess.ps1
|
function Grant-UTCMWorkloadAccess { <# .SYNOPSIS Grants the UTCM service principal the minimum rights needed to READ tenant configuration across selected workloads. .DESCRIPTION Assigns Graph app roles plus — for Exchange-backed workloads — Exchange Online RBAC management role assignments scoped exclusively to the UTCM service principal. No tenant-wide Entra directory roles are used for Exchange or S&C workloads. Teams requires the Global Reader Entra directory role (per official UTCM Teams resource docs). Permission strategy per workload: - Entra: Graph app roles Policy.Read.All + Directory.Read.All (Conditional Access policies & directory objects) # [2] - Exchange: Graph app role Exchange.ManageAsApp (app-only EXO authentication) # [3][4] + EXO RBAC roles View-Only Configuration & View-Only Recipients # [9] (reads transport rules, shared mailboxes, org config — scoped to the SP, not tenant-wide) - Intune: Graph app roles DeviceManagementConfiguration.Read.All, DeviceManagementRBAC.Read.All, # [5] DeviceManagementManagedDevices.Read.All, DeviceManagementApps.Read.All, DeviceManagementServiceConfig.Read.All, Group.Read.All (covers device categories, app policies, enrollment, compliance, autopilot, etc.) - SecurityAndCompliance: Exchange.ManageAsApp + InformationProtectionConfig.Read.All # [6] + Directory.Read.All + EXO RBAC roles View-Only Configuration & Security Reader (EXO role, NOT the Entra directory role) # [9] - Teams: Graph app roles Organization.Read.All + TeamSettings.Read.All # [7][8] + Entra directory role Global Reader (required by UTCM for all Teams resources) # [10] References: [1] https://learn.microsoft.com/en-us/graph/utcm-authentication-setup [2] https://learn.microsoft.com/en-us/graph/api/conditionalaccesspolicy-get?view=graph-rest-1.0 [3] https://learn.microsoft.com/en-us/powershell/exchange/app-only-auth-powershell-v2?view=exchange-ps [4] https://learn.microsoft.com/en-us/services-hub/unified/health/getting-started-office365exchange/app-auth [5] https://learn.microsoft.com/en-us/graph/api/intune-deviceconfigv2-devicemanagementconfigurationpolicy-list?view=graph-rest-beta [6] https://learn.microsoft.com/en-us/graph/utcm-securityandcompliance-resources [7] https://graphpermissions.merill.net/permission/TeamSettings.Read.All [8] https://learn.microsoft.com/en-us/graph/api/teamsappsettings-get?view=graph-rest-1.0 [9] https://learn.microsoft.com/en-us/graph/utcm-exchange-resources https://learn.microsoft.com/en-us/exchange/permissions-exo/application-rbac [10] https://learn.microsoft.com/en-us/graph/utcm-teams-resources .PARAMETER Workloads One or more of: Entra, Exchange, Intune, SecurityAndCompliance, Teams. Defaults to all. .EXAMPLE Grant-UTCMWorkloadAccess -Workloads Entra,Exchange,Intune -Verbose .EXAMPLE # Grant only Exchange (includes EXO RBAC setup — requires ExchangeOnlineManagement module) Grant-UTCMWorkloadAccess -Workloads Exchange #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact='Medium')] param( [ValidateSet('Entra','Exchange','Intune','SecurityAndCompliance','Teams')] [string[]]$Workloads = @('Entra','Exchange','Intune','SecurityAndCompliance','Teams') ) # Ensure Graph connection for app-role assignments Ensure-GraphConnection -Scopes @('Application.ReadWrite.All','AppRoleAssignment.ReadWrite.All','Directory.ReadWrite.All') # [1] $utcmAppId = '03b07b79-c5bc-4b5e-9bfa-13acf4a99998' # Official UTCM AppId [1] try { $utcm = Get-MgServicePrincipal -Filter "appId eq '$utcmAppId'" -All -ErrorAction Stop if (-not $utcm) { $utcm = Enable-UTCM } } catch { Write-Log -Color Red -Message "Unable to resolve UTCM SP: $($_.Exception.Message)" throw } # Resource AppIds $graphAppId = '00000003-0000-0000-c000-000000000000' # Microsoft Graph $exoAppId = '00000002-0000-0ff1-ce00-000000000000' # Office 365 Exchange Online # ── Local helpers ────────────────────────────────────────────── function _Grant-AppRole { param([string]$PrincipalObjectId, [string]$ResourceAppId, [string]$RoleValue) try { $resourceSp = Get-MgServicePrincipal -Filter "appId eq '$ResourceAppId'" -All -ErrorAction Stop $role = $resourceSp.AppRoles | Where-Object { $_.Value -eq $RoleValue -and $_.AllowedMemberTypes -contains 'Application' -and $_.IsEnabled } if (-not $role) { throw "Role '$RoleValue' not found on resource AppId $ResourceAppId." } $exists = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $PrincipalObjectId -All | Where-Object { $_.ResourceId -eq $resourceSp.Id -and $_.AppRoleId -eq $role.Id } if ($exists) { Write-Log -Color Gray -Message "App role '$RoleValue' already assigned on resource '$($resourceSp.DisplayName)'." return } if ($PSCmdlet.ShouldProcess("SP:$PrincipalObjectId", "Grant role '$RoleValue' on '$($resourceSp.DisplayName)'")) { $body = @{ PrincipalId = $PrincipalObjectId; ResourceId = $resourceSp.Id; AppRoleId = $role.Id } New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $PrincipalObjectId -BodyParameter $body -ErrorAction Stop | Out-Null Write-Log -Color Green -Message "Granted '$RoleValue' on '$($resourceSp.DisplayName)'." } } catch { Write-Log -Color Red -Message "Granting app role '$RoleValue' failed: $($_.Exception.Message)" throw } } # Ensure the UTCM SP is registered inside Exchange Online and assign EXO RBAC management roles. # This uses the ExchangeOnlineManagement module (Connect-ExchangeOnline must be available). # Roles are scoped to the SP — NOT tenant-wide Entra directory roles. # [9] function _Grant-ExoRbacRoles { param( [string]$AppId, [string]$ObjectId, [string]$DisplayName, [string[]]$ManagementRoles # e.g. 'View-Only Configuration', 'View-Only Recipients' ) # Verify ExchangeOnlineManagement is available if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) { throw ("ExchangeOnlineManagement module is required for Exchange RBAC grants. " + "Install it with: Install-Module ExchangeOnlineManagement -Scope CurrentUser") } # Connect if not already connected (will prompt interactively) Import-Module ExchangeOnlineManagement -ErrorAction Stop $exoConn = Get-ConnectionInformation -ErrorAction SilentlyContinue if (-not $exoConn) { Write-Log -Color Cyan -Message "Connecting to Exchange Online..." try { Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop } catch { # WAM broker can crash in some PowerShell hosts — fall back to device-code flow Write-Log -Color Yellow -Message "WAM broker auth failed ($($_.Exception.Message)). Falling back to device-code flow..." Connect-ExchangeOnline -ShowBanner:$false -Device -ErrorAction Stop } } else { Write-Log -Color Gray -Message "Already connected to Exchange Online ($($exoConn[0].Organization))." } # Register the UTCM SP inside EXO (idempotent — will error if already exists) try { $exoSp = Get-ServicePrincipal -Identity $AppId -ErrorAction Stop Write-Log -Color Gray -Message "EXO service principal already registered: $($exoSp.DisplayName)" } catch { if ($PSCmdlet.ShouldProcess("AppId:$AppId", "Register service principal in Exchange Online")) { Write-Log -Color Cyan -Message "Registering UTCM service principal in Exchange Online..." $exoSp = New-ServicePrincipal -AppId $AppId -ObjectId $ObjectId -DisplayName $DisplayName -ErrorAction Stop Write-Log -Color Green -Message "Registered EXO service principal: $($exoSp.DisplayName)" } } # Assign each management role (idempotent — skip if already assigned) $shortId = $AppId.Substring(0, 8) # first segment of GUID — keeps name under 64 chars foreach ($roleName in $ManagementRoles) { $assignmentName = ("UTCM_${roleName}_$shortId" -replace ' ', '_').Substring(0, [Math]::Min(64, ("UTCM_${roleName}_$shortId" -replace ' ', '_').Length)) $existing = Get-ManagementRoleAssignment -RoleAssignee $ObjectId -ErrorAction SilentlyContinue | Where-Object { $_.Role -eq $roleName } if ($existing) { Write-Log -Color Gray -Message "EXO role '$roleName' already assigned to UTCM SP." continue } if ($PSCmdlet.ShouldProcess("SP:$ObjectId", "Assign EXO management role '$roleName'")) { New-ManagementRoleAssignment -Name $assignmentName -Role $roleName -App $ObjectId -ErrorAction Stop | Out-Null Write-Log -Color Green -Message "Assigned EXO management role '$roleName' to UTCM SP." } } } # Assign Entra directory roles (only for workloads that require them — currently Teams only) function _Ensure-DirectoryRoleMember { param([string]$RoleDisplayName, [string]$PrincipalObjectId) $role = Get-MgDirectoryRole -Filter "displayName eq '$RoleDisplayName'" -ErrorAction SilentlyContinue if (-not $role) { # Activate from template if not yet instantiated $tmpl = Get-MgDirectoryRoleTemplate -All | Where-Object { $_.DisplayName -eq $RoleDisplayName } if (-not $tmpl) { throw "Directory role template '$RoleDisplayName' not found." } $role = New-MgDirectoryRole -BodyParameter @{ roleTemplateId = $tmpl.Id } -ErrorAction Stop } $existing = Get-MgDirectoryRoleMember -DirectoryRoleId $role.Id -All | Where-Object { $_.Id -eq $PrincipalObjectId } if ($existing) { Write-Log -Color Gray -Message "Directory role '$RoleDisplayName' already assigned to UTCM SP." return } if ($PSCmdlet.ShouldProcess("SP:$PrincipalObjectId", "Assign Entra directory role '$RoleDisplayName'")) { $body = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$PrincipalObjectId" } try { New-MgDirectoryRoleMemberByRef -DirectoryRoleId $role.Id -BodyParameter $body -ErrorAction Stop Write-Log -Color Green -Message "Assigned Entra directory role '$RoleDisplayName' to UTCM SP." } catch { # Handle "already exists" error gracefully (member might have been added since the last check) if ($_.Exception.Message -match 'added object references already exist|Request_BadRequest') { Write-Log -Color Gray -Message "Directory role '$RoleDisplayName' already assigned to UTCM SP." return } if ($_.Exception.Message -match 'Authorization_RequestDenied|Insufficient privileges') { throw "Cannot assign Entra directory role '$RoleDisplayName': your Graph session lacks the 'RoleManagement.ReadWrite.Directory' scope. " + "Reconnect with: Connect-MgGraph -Scopes 'RoleManagement.ReadWrite.Directory' then retry." } throw } } } # ── Workload → Graph app-role grants mapping ────────────────── $map = @{ 'Entra' = @( @{ ResourceAppId = $graphAppId; RoleValue = 'Policy.Read.All' }, # CA policies read [2] @{ ResourceAppId = $graphAppId; RoleValue = 'Directory.Read.All' } # directory read ) 'Exchange' = @( @{ ResourceAppId = $exoAppId; RoleValue = 'Exchange.ManageAsApp' } # EXO app-only auth [3][4] ) 'Intune' = @( @{ ResourceAppId = $graphAppId; RoleValue = 'DeviceManagementConfiguration.Read.All' }, # config policies [5] @{ ResourceAppId = $graphAppId; RoleValue = 'DeviceManagementRBAC.Read.All'}, # role assignments @{ ResourceAppId = $graphAppId; RoleValue = 'DeviceManagementManagedDevices.Read.All'}, # device categories @{ ResourceAppId = $graphAppId; RoleValue = 'DeviceManagementApps.Read.All'}, # app config/protection @{ ResourceAppId = $graphAppId; RoleValue = 'DeviceManagementServiceConfig.Read.All'}, # enrollment/autopilot @{ ResourceAppId = $graphAppId; RoleValue = 'Group.Read.All'} # group assignments ) 'SecurityAndCompliance' = @( @{ ResourceAppId = $exoAppId; RoleValue = 'Exchange.ManageAsApp' }, # S&C EXO auth [6] @{ ResourceAppId = $graphAppId; RoleValue = 'InformationProtectionConfig.Read.All'}, # Purview @{ ResourceAppId = $graphAppId; RoleValue = 'Directory.Read.All'} # Defender ) 'Teams' = @( @{ ResourceAppId = $graphAppId; RoleValue = 'Organization.Read.All' }, # org settings @{ ResourceAppId = $graphAppId; RoleValue = 'TeamSettings.Read.All' } # Teams admin [7][8] ) } # ── Workload → Entra directory role mapping ─────────────────── # Only Teams requires a directory role — all Teams UTCM resources mandate Global Reader [10] $directoryRoleMap = @{ 'Teams' = @('Global Reader') } # ── Workload → EXO RBAC management role mapping ─────────────── # These are Exchange Online management roles assigned via -App, NOT Entra directory roles. # Per https://learn.microsoft.com/en-us/graph/utcm-exchange-resources: # sharedMailbox → View-Only Recipients, Mail Recipients # transportRule → View-Only Configuration, Transport Rules, Security Reader (EXO role) # We grant the superset that covers all Exchange + S&C UTCM resource types for read. $exoRbacMap = @{ 'Exchange' = @( 'View-Only Configuration', # transport rules, org config, connectors, policies 'View-Only Recipients' # shared mailboxes, distribution groups, mail contacts ) 'SecurityAndCompliance' = @( 'View-Only Configuration', # needed by S&C EXO-backed resources 'Security Reader' # EXO management role (NOT the Entra directory role) ) } try { foreach ($wl in $Workloads) { Write-Log -Color Cyan -Message "Granting UTCM access for workload: $wl" # Grant Graph / EXO app roles foreach ($grant in $map[$wl]) { _Grant-AppRole -PrincipalObjectId $utcm.Id -ResourceAppId $grant.ResourceAppId -RoleValue $grant.RoleValue } # Grant EXO RBAC management roles (Exchange and S&C workloads only) if ($exoRbacMap.ContainsKey($wl)) { Write-Log -Color Cyan -Message "Configuring Exchange Online RBAC roles for workload: $wl" _Grant-ExoRbacRoles -AppId $utcmAppId -ObjectId $utcm.Id ` -DisplayName 'Unified Tenant Configuration Management' ` -ManagementRoles $exoRbacMap[$wl] } # Grant Entra directory roles (Teams only) if ($directoryRoleMap.ContainsKey($wl)) { foreach ($roleName in $directoryRoleMap[$wl]) { _Ensure-DirectoryRoleMember -RoleDisplayName $roleName -PrincipalObjectId $utcm.Id } } } Write-Log -Color Green -Message ("UTCM workload access granted for: " + ($Workloads -join ', ')) } catch { Write-Log -Color Red -Message "Grant-UTCMWorkloadAccess failed: $($_.Exception.Message)" throw } } |