Public/Setup/Grant-TBServicePrincipalPermission.ps1

function Grant-TBServicePrincipalPermission {
    <#
    .SYNOPSIS
        Grants UTCM service principal permissions.
    .DESCRIPTION
        Uses a resource/workload-aware permission plan. Auto-grants assignable
        Microsoft Graph app roles and returns guided manual remediation steps for
        provider-specific permissions that can't be automatically granted.
    .PARAMETER Workload
        The workload to grant permissions for.
    .PARAMETER ResourceType
        One or more resource types to derive a permission plan from.
    .PARAMETER PlanOnly
        Returns the permission plan without granting permissions.
    #>

    [CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'ByWorkload')]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = 'ByWorkload')]
        [ValidateSet('ConditionalAccess', 'EntraID', 'ExchangeOnline', 'Intune', 'Teams', 'SecurityAndCompliance', 'SharePoint', 'MultiWorkload')]
        [string]$Workload,

        [Parameter(Mandatory = $true, ParameterSetName = 'ByResourceType')]
        [string[]]$ResourceType,

        [Parameter()]
        [switch]$PlanOnly
    )

    $null = Test-TBGraphConnection

    if ($PSCmdlet.ParameterSetName -eq 'ByWorkload') {
        $plan = Get-TBPermissionPlan -Workload $Workload
    }
    else {
        $plan = Get-TBPermissionPlan -ResourceType $ResourceType
    }

    if ($PlanOnly) {
        return $plan
    }

    $requiredPermissions = @($plan.AutoGrantGraphPermissions)
    try {
        $context = Get-MgContext
    }
    catch {
        $context = $null
    }

    if ($requiredPermissions.Count -gt 0 -and $context -and $context.Scopes) {
        $scopeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($scope in @($context.Scopes)) {
            if ($scope) {
                $null = $scopeSet.Add($scope)
            }
        }

        if (-not $scopeSet.Contains('Application.ReadWrite.All')) {
            throw 'Granting UTCM service principal permissions requires Application.ReadWrite.All. Reconnect using Connect-TBTenant -Scenario Setup.'
        }
    }

    # Find the UTCM service principal
    $appId = $script:UTCMAppId
    $filterUri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$appId'"
    $spResponse = Invoke-TBGraphRequest -Uri $filterUri -Method 'GET'

    $spItems = $null
    if ($spResponse -is [hashtable] -and $spResponse.ContainsKey('value')) {
        $spItems = $spResponse['value']
    }
    elseif ($spResponse.PSObject.Properties['value']) {
        $spItems = $spResponse.value
    }

    if (-not $spItems -or @($spItems).Count -eq 0) {
        throw 'UTCM service principal not found. Run Install-TBServicePrincipal first.'
    }

    $sp = $spItems[0]
    $spId = if ($sp -is [hashtable]) { $sp['id'] } else { $sp.id }

    # Find Microsoft Graph service principal to get role IDs
    $graphAppId = '00000003-0000-0000-c000-000000000000'
    $graphFilterUri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$graphAppId'"
    $graphSpResponse = Invoke-TBGraphRequest -Uri $graphFilterUri -Method 'GET'

    $graphItems = $null
    if ($graphSpResponse -is [hashtable] -and $graphSpResponse.ContainsKey('value')) {
        $graphItems = $graphSpResponse['value']
    }
    elseif ($graphSpResponse.PSObject.Properties['value']) {
        $graphItems = $graphSpResponse.value
    }

    if (-not $graphItems -or @($graphItems).Count -eq 0) {
        throw 'Microsoft Graph service principal not found in tenant.'
    }

    $graphSp = $graphItems[0]
    $graphSpId = if ($graphSp -is [hashtable]) { $graphSp['id'] } else { $graphSp.id }
    $appRoles = if ($graphSp -is [hashtable]) { $graphSp['appRoles'] } else { $graphSp.appRoles }

    # Build a set of already-assigned Graph app roles for the UTCM SP so we can
    # avoid duplicate POST attempts that often surface as generic BadRequest.
    $existingGraphRoleAssignments = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    try {
        $assignmentUri = 'https://graph.microsoft.com/v1.0/servicePrincipals/{0}/appRoleAssignments' -f $spId
        $assignments = Invoke-TBGraphPagedRequest -Uri $assignmentUri
        foreach ($assignment in @($assignments)) {
            $assignmentResourceId = if ($assignment -is [hashtable]) { $assignment['resourceId'] } else { $assignment.resourceId }
            $assignmentRoleId = if ($assignment -is [hashtable]) { $assignment['appRoleId'] } else { $assignment.appRoleId }
            if (-not $assignmentResourceId -or -not $assignmentRoleId) {
                continue
            }

            if ($assignmentResourceId.ToString() -eq $graphSpId.ToString()) {
                $null = $existingGraphRoleAssignments.Add($assignmentRoleId.ToString())
            }
        }
    }
    catch {
        Write-TBLog -Message ('Unable to pre-load existing app role assignments: {0}' -f $_.Exception.Message) -Level 'Warning'
    }

    $grantedCount = 0
    $alreadyGrantedCount = 0
    $missingRoles = [System.Collections.ArrayList]::new()
    $failedRoles = [System.Collections.ArrayList]::new()

    foreach ($permission in $requiredPermissions) {
        $role = $null
        foreach ($appRole in $appRoles) {
            $roleValue = if ($appRole -is [hashtable]) { $appRole['value'] } else { $appRole.value }
            if ($roleValue -eq $permission) {
                $role = $appRole
                break
            }
        }

        if (-not $role) {
            Write-TBLog -Message ('App role not found for permission: {0}' -f $permission) -Level 'Warning'
            $null = $missingRoles.Add($permission)
            continue
        }

        $roleId = if ($role -is [hashtable]) { $role['id'] } else { $role.id }
        if ($roleId -and $existingGraphRoleAssignments.Contains($roleId.ToString())) {
            Write-TBLog -Message ('Permission already granted: {0}' -f $permission)
            $alreadyGrantedCount++
            continue
        }

        if ($PSCmdlet.ShouldProcess($permission, 'Grant permission to UTCM service principal')) {
            try {
                $body = @{
                    principalId = $spId
                    resourceId  = $graphSpId
                    appRoleId   = $roleId
                }

                $grantUri = 'https://graph.microsoft.com/v1.0/servicePrincipals/{0}/appRoleAssignments' -f $spId
                $null = Invoke-TBGraphRequest -Uri $grantUri -Method 'POST' -Body $body
                Write-TBLog -Message ('Granted permission: {0}' -f $permission)
                $grantedCount++
            }
            catch {
                $errorMsg = $_.Exception.Message
                if ($errorMsg -match 'already exists') {
                    Write-TBLog -Message ('Permission already granted: {0}' -f $permission)
                    $alreadyGrantedCount++
                }
                else {
                    Write-TBLog -Message ('Failed to grant {0}: {1}' -f $permission, $errorMsg) -Level 'Warning'
                    if (-not $failedRoles.Contains($permission)) {
                        $null = $failedRoles.Add($permission)
                    }
                }
            }
        }
    }

    [PSCustomObject]@{
        PSTypeName                  = 'TenantBaseline.PermissionGrantResult'
        Workloads                   = $plan.RequestedWorkloads
        ResourceTypes               = $plan.CanonicalResourceTypes
        PermissionsPlanned          = $requiredPermissions.Count
        PermissionsGranted          = $grantedCount
        PermissionsAlreadyGranted   = $alreadyGrantedCount
        PermissionsMissingInTenant  = @($missingRoles)
        PermissionsFailedToGrant    = @($failedRoles)
        ManualSteps                 = $plan.ManualSteps
        CompatibilityNotes          = $plan.CompatibilityNotes
    }
}