ScriptBlocks/Grant-Access-SitePermission.ps1

param(
    [Parameter(Mandatory=$true)] [ValidateSet('grant','read','delete')] [string]$Mode,

    [Parameter(Mandatory=$true)] [string]$ClientId,
    [Parameter(Mandatory=$true)] [string]$TenantId,
    [Parameter(Mandatory=$true)] [string]$Thumbprint,

    [Parameter(Mandatory=$true)] [string]$TenantHost,
    [Parameter(Mandatory=$true)] [string]$SitePath,
    [Parameter(Mandatory=$true)] [string]$ContainerName,
    [Parameter(Mandatory=$true)] [string]$FolderPath,
    [Parameter(Mandatory=$false)]
    [AllowEmptyString()]
    [string]$FilePath = '',
    [Parameter(Mandatory=$true)] [string]$TargetAppId,

    [ValidateSet('read','write')] [string]$Role = 'read',

    [switch]$ForceUpgrade,
    [switch]$CleanDuplicates,
    [switch]$AllowRoot
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$VerbosePreference = 'SilentlyContinue'
$WarningPreference = 'SilentlyContinue'
$InformationPreference = 'SilentlyContinue'

function _RoleRank([string[]]$roles) {
    $r = @($roles)
    if ($r -contains 'owner') { return 3 }
    if ($r -contains 'write') { return 2 }
    if ($r -contains 'read')  { return 1 }
    return 0
}

function _Satisfies([string[]]$existingRoles, [string]$requestedRole) {
    $r = @($existingRoles)
    if ($requestedRole -eq 'read')  { return ($r -contains 'read' -or $r -contains 'write' -or $r -contains 'owner') }
    if ($requestedRole -eq 'write') { return ($r -contains 'write' -or $r -contains 'owner') }
    return $false
}

function _GetAppPermissionMatches($permissions, [string]$appId) {
    $pmatches = @()

    foreach ($p in @($permissions)) {

        if ($p.grantedToV2 -and $p.grantedToV2.application -and $p.grantedToV2.application.id -eq $appId) {
            $pmatches += $p
            continue
        }

        if ($p.grantedToIdentitiesV2) {
            foreach ($idset in @($p.grantedToIdentitiesV2)) {
                if ($idset.application -and $idset.application.id -eq $appId) {
                    $pmatches += $p
                    break
                }
            }
        }

        if ($p.grantedToIdentities) {
            foreach ($idset in @($p.grantedToIdentities)) {
                if ($idset.application -and $idset.application.id -eq $appId) {
                    $pmatches += $p
                    break
                }
            }
        }
    }

    return $pmatches
}
function Test-WorkerAppHasGraphAppRoles {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$WorkerAppId,
        [Parameter(Mandatory)] [string[]]$RequiredRoles
    )

    try {
        $graphSp = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'&`$select=id,appRoles").value | Select-Object -First 1
        if (-not $graphSp) { throw "Microsoft Graph servicePrincipal not found." }

        $workerSp = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$WorkerAppId'&`$select=id,appId").value | Select-Object -First 1
        if (-not $workerSp) { throw "Worker servicePrincipal not found for appId '$WorkerAppId'." }

        $assignments = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($workerSp.id)/appRoleAssignments").value

        $roleIdToValue = @{}
        foreach ($r in $graphSp.appRoles) { $roleIdToValue[$r.id] = $r.value }

        $granted = @()
        foreach ($a in $assignments) {
            if ($a.resourceId -eq $graphSp.id -and $roleIdToValue.ContainsKey($a.appRoleId)) {
                $granted += $roleIdToValue[$a.appRoleId]
            }
        }

        $missing = @($RequiredRoles | Where-Object { $_ -notin $granted })

        return [pscustomobject]@{
            Status        = if ($missing.Count -eq 0) { 'Pass' } else { 'Fail' }
            RequiredRoles = $RequiredRoles
            GrantedRoles  = ($granted | Sort-Object -Unique)
            MissingRoles  = $missing
            Note          = ''
        }
    }
    catch {
        return [pscustomobject]@{
            Status        = 'Skipped'
            RequiredRoles = $RequiredRoles
            GrantedRoles  = @()
            MissingRoles  = @()
            Note          = "Skipped directory-based validation (likely missing Application.Read.All/Directory.Read.All on provisioning identity). Error: $($_.Exception.Message)"
        }
    }
}

function Test-WorkerAppPermissionsExtended {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string]$WorkerAppId,
        [Parameter(Mandatory)] [string[]]$RequiredGraphRoles
    )

    try {

        $graphSp = Invoke-MgGraphRequest -Method GET `
            -Uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='00000003-0000-0000-c000-000000000000')?`$select=id,appRoles"

        if (-not $graphSp) { throw "Graph SP not found" }

        $workerSp = (Invoke-MgGraphRequest -Method GET `
            -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$WorkerAppId'&`$select=id,appId").value |
            Select-Object -First 1

        if (-not $workerSp) { throw "Worker SP not found for appId '$WorkerAppId'" }

        $spoSp = (Invoke-MgGraphRequest -Method GET `
            -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=startswith(displayName,'SharePoint')&`$select=id,displayName").value |
            Select-Object -First 1

        if (-not $spoSp) { throw "SharePoint SP not found" }

        $graphRolesMap = @{}
        foreach ($r in $graphSp.appRoles) {
            $graphRolesMap[$r.id] = $r.value
        }

        $spoSpFull = Invoke-MgGraphRequest -Method GET `
            -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($spoSp.id)?`$select=appRoles"

        $spoRolesMap = @{}
        foreach ($r in $spoSpFull.appRoles) {
            $spoRolesMap[$r.id] = $r.value
        }

        $assignments = (Invoke-MgGraphRequest -Method GET `
            -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($workerSp.id)/appRoleAssignments").value

        $graphGranted = @()
        $spoGranted   = @()

        foreach ($a in $assignments) {

            if ($a.resourceId -eq $graphSp.id -and $graphRolesMap.ContainsKey($a.appRoleId)) {
                $graphGranted += $graphRolesMap[$a.appRoleId]
            }

            if ($a.resourceId -eq $spoSp.id -and $spoRolesMap.ContainsKey($a.appRoleId)) {
                $spoGranted += $spoRolesMap[$a.appRoleId]
            }
        }

        $graphGranted = $graphGranted | Sort-Object -Unique
        $spoGranted   = $spoGranted | Sort-Object -Unique

        $missingGraph = @(
            $RequiredGraphRoles |
            Where-Object { $_ -notin $graphGranted }
        )

        return [pscustomobject]@{

            Graph = [pscustomobject]@{
                GrantedRoles = $graphGranted
                MissingRoles = $missingGraph
            }

            SharePoint = [pscustomobject]@{
                GrantedRoles = $spoGranted
                MissingRoles = @()
            }

            Summary = [pscustomobject]@{
                HasGraphSitesSelected      = ($graphGranted -contains "Sites.Selected")
                HasSharePointSitesSelected = ($spoGranted   -contains "Sites.Selected")

                EffectiveScopes = @(
                    $graphGranted
                    $spoGranted
                ) | Sort-Object -Unique
            }

            Status = if ($missingGraph.Count -eq 0) { "Pass" } else { "Fail" }
        }
    }
    catch {
        return [pscustomobject]@{
            Status = "Skipped"
            Error  = $_.Exception.Message
        }
    }
}

function Test-WorkerAppPermissions_ExtendedWithDelegated {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$WorkerAppId,

        [Parameter()]
        [string[]]$RequiredGraphAppRoles = @(),

        [Parameter()]
        [string[]]$RequiredGraphDelegatedScopes = @(),

        [Parameter()]
        [string[]]$RequiredSharePointAppRoles = @(),

        [Parameter()]
        [string[]]$RequiredSharePointDelegatedScopes = @()
    )

    $MicrosoftGraphAppId = '00000003-0000-0000-c000-000000000000'
    $SharePointOnlineAppId = '00000003-0000-0ff1-ce00-000000000000'

    $SupportedGraphSelectedPermissions = @(
        'Sites.Selected'
        'Lists.SelectedOperations.Selected'
        'ListItems.SelectedOperations.Selected'
        'Files.SelectedOperations.Selected'
    )

    $SupportedSharePointRestPermissions = @(
        'Sites.Selected'
    )

    function Test-LocalBlank {
        [CmdletBinding()]
        param(
            [Parameter()]
            [AllowNull()]
            [object]$Value
        )

        if ($null -eq $Value) {
            return $true
        }

        return ([string]$Value).Trim().Length -eq 0
    }

    function ConvertTo-NormalizedStringArray {
        [CmdletBinding()]
        param(
            [Parameter()]
            [AllowNull()]
            [object[]]$Value
        )

        return @(
            $Value |
                Where-Object {
                    $null -ne $_ -and
                    -not (Test-LocalBlank $_)
                } |
                ForEach-Object {
                    ([string]$_).Trim()
                } |
                Sort-Object -Unique
        )
    }

    function Get-MissingValues {
        [CmdletBinding()]
        param(
            [Parameter()]
            [AllowNull()]
            [object[]]$Granted,

            [Parameter()]
            [AllowNull()]
            [object[]]$Required
        )

        $normalizedGranted = @(
            ConvertTo-NormalizedStringArray -Value $Granted
        )

        $normalizedRequired = @(
            ConvertTo-NormalizedStringArray -Value $Required
        )

        return @(
            $normalizedRequired |
                Where-Object {
                    $_ -notin $normalizedGranted
                }
        )
    }

    function Get-GraphCollection {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string]$Uri
        )

        $results = New-Object System.Collections.Generic.List[object]
        $nextUri = $Uri

        while (-not (Test-LocalBlank $nextUri)) {
            $response = Invoke-MgGraphRequest `
                -Method GET `
                -Uri $nextUri `
                -ErrorAction Stop

            if ($null -ne $response.value) {
                foreach ($item in @($response.value)) {
                    [void]$results.Add($item)
                }
            }
            elseif ($null -ne $response) {
                [void]$results.Add($response)
            }

            $nextUri = $null

            if (
                $null -ne $response -and
                $response.PSObject.Properties.Name -contains '@odata.nextLink'
            ) {
                $nextUri = [string]$response.'@odata.nextLink'
            }
        }

        return @($results.ToArray())
    }

    function Get-SingleServicePrincipalByAppId {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string]$AppId,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string]$Description,

            [Parameter()]
            [ValidateNotNullOrEmpty()]
            [string]$SelectProperties = 'id,appId,displayName'
        )

        $escapedAppId = $AppId.Replace("'", "''")

        $uri =
            'https://graph.microsoft.com/v1.0/servicePrincipals' +
            "?`$filter=appId eq '$escapedAppId'" +
            "&`$select=$SelectProperties"

        $matches = @(
            Get-GraphCollection -Uri $uri
        )

        if ($matches.Count -eq 0) {
            throw (
                "$Description service principal was not found " +
                "for AppId '$AppId'."
            )
        }

        if ($matches.Count -gt 1) {
            throw (
                "Multiple service principals were found for " +
                "$Description AppId '$AppId'."
            )
        }

        return $matches[0]
    }

    function New-FailedValidationResult {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [string]$ErrorMessage
        )

        return [pscustomobject]@{
            Status = 'Skipped'
            Error  = $ErrorMessage

            Worker = [pscustomobject]@{
                ServicePrincipalId = ''
                AppId              = $WorkerAppId
                DisplayName        = ''
            }

            Graph = [pscustomobject]@{
                ServicePrincipalId          = ''
                ServicePrincipalAppId       = $MicrosoftGraphAppId
                ServicePrincipalDisplayName = 'Microsoft Graph'

                Application = [pscustomobject]@{
                    GrantedRoles  = @()
                    RequiredRoles = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredGraphAppRoles
                    )
                    MissingRoles = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredGraphAppRoles
                    )
                }

                Delegated = [pscustomobject]@{
                    GrantedScopes   = @()
                    RequiredScopes  = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredGraphDelegatedScopes
                    )
                    MissingScopes   = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredGraphDelegatedScopes
                    )
                    DetectionStatus = 'Skipped'
                    Note            = $ErrorMessage
                }

                SupportedSelectedPermissions = @(
                    $SupportedGraphSelectedPermissions
                )

                SelectedApplicationRolesGranted = @()
                HasSitesSelectedApplicationRole = $false
                RequirementsSpecified = $false
                RequirementsSatisfied = $false
                IsUsableForSelectedApplicationAccess = $false
            }

            SharePoint = [pscustomobject]@{
                ServicePrincipalId          = ''
                ServicePrincipalAppId       = $SharePointOnlineAppId
                ServicePrincipalDisplayName = 'Office 365 SharePoint Online'

                Application = [pscustomobject]@{
                    GrantedRoles  = @()
                    RequiredRoles = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredSharePointAppRoles
                    )
                    MissingRoles = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredSharePointAppRoles
                    )
                }

                Delegated = [pscustomobject]@{
                    GrantedScopes   = @()
                    RequiredScopes  = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredSharePointDelegatedScopes
                    )
                    MissingScopes   = @(
                        ConvertTo-NormalizedStringArray `
                            -Value $RequiredSharePointDelegatedScopes
                    )
                    DetectionStatus = 'Skipped'
                    Note            = $ErrorMessage
                }

                SupportedRestPermissions = @(
                    $SupportedSharePointRestPermissions
                )

                RestApplicationRolesGranted = @()
                HasSitesSelectedApplicationRole = $false
                RequirementsSpecified = $false
                RequirementsSatisfied = $false
                IsUsableForSelectedApplicationAccess = $false
            }

            Summary = [pscustomobject]@{
                WorkerAppId = $WorkerAppId

                EvaluationMode = 'Failed'

                GraphRequirementsSpecified = $false
                SharePointRequirementsSpecified = $false
                AnyRequirementsSpecified = $false

                EffectiveApplicationRoles = @()
                EffectiveDelegatedScopes  = @()

                GraphApiUsable = $false
                SharePointRestApiUsable = $false
                AnyApplicationApiUsable = $false

                HasGraphSitesSelectedAppRole = $false
                HasSharePointSitesSelectedAppRole = $false
                BothApisMissingSitesSelected = $true

                RequestedPermissionsSatisfied = $false
            }
        }
    }

    try {

        $requiredGraphApp = @(
            ConvertTo-NormalizedStringArray `
                -Value $RequiredGraphAppRoles
        )

        $requiredGraphDelegated = @(
            ConvertTo-NormalizedStringArray `
                -Value $RequiredGraphDelegatedScopes
        )

        $requiredSharePointApp = @(
            ConvertTo-NormalizedStringArray `
                -Value $RequiredSharePointAppRoles
        )

        $requiredSharePointDelegated = @(
            ConvertTo-NormalizedStringArray `
                -Value $RequiredSharePointDelegatedScopes
        )

        $graphRequirementsSpecified = (
            $requiredGraphApp.Count -gt 0 -or
            $requiredGraphDelegated.Count -gt 0
        )

        $sharePointRequirementsSpecified = (
            $requiredSharePointApp.Count -gt 0 -or
            $requiredSharePointDelegated.Count -gt 0
        )

        $anyRequirementsSpecified = (
            $graphRequirementsSpecified -or
            $sharePointRequirementsSpecified
        )

        $evaluationMode = if ($anyRequirementsSpecified) {
            'RequestedPermissions'
        }
        else {
            'Discovery'
        }

        $graphSp = Get-SingleServicePrincipalByAppId `
            -AppId $MicrosoftGraphAppId `
            -Description 'Microsoft Graph' `
            -SelectProperties 'id,appId,displayName,appRoles'

        $sharePointSp = Get-SingleServicePrincipalByAppId `
            -AppId $SharePointOnlineAppId `
            -Description 'Office 365 SharePoint Online' `
            -SelectProperties 'id,appId,displayName,appRoles'

        $workerSp = Get-SingleServicePrincipalByAppId `
            -AppId $WorkerAppId `
            -Description 'Worker application' `
            -SelectProperties 'id,appId,displayName'

        $graphRoleIdToValue = @{}

        foreach ($role in @($graphSp.appRoles)) {
            $roleId = [string]$role.id
            $roleValue = [string]$role.value

            if (
                -not (Test-LocalBlank $roleId) -and
                -not (Test-LocalBlank $roleValue)
            ) {
                $graphRoleIdToValue[$roleId] = $roleValue
            }
        }

        $sharePointRoleIdToValue = @{}

        foreach ($role in @($sharePointSp.appRoles)) {
            $roleId = [string]$role.id
            $roleValue = [string]$role.value

            if (
                -not (Test-LocalBlank $roleId) -and
                -not (Test-LocalBlank $roleValue)
            ) {
                $sharePointRoleIdToValue[$roleId] = $roleValue
            }
        }

        $assignmentUri =
            'https://graph.microsoft.com/v1.0/servicePrincipals/' +
            $workerSp.id +
            '/appRoleAssignments' +
            '?$select=id,appRoleId,resourceId,resourceDisplayName'

        $assignments = @(
            Get-GraphCollection -Uri $assignmentUri
        )

        $graphAppRoleList =
            New-Object System.Collections.Generic.List[string]

        $sharePointAppRoleList =
            New-Object System.Collections.Generic.List[string]

        foreach ($assignment in $assignments) {
            $resourceId = [string]$assignment.resourceId
            $appRoleId = [string]$assignment.appRoleId

            if (
                $resourceId -eq [string]$graphSp.id -and
                $graphRoleIdToValue.ContainsKey($appRoleId)
            ) {
                [void]$graphAppRoleList.Add(
                    $graphRoleIdToValue[$appRoleId]
                )
            }

            if (
                $resourceId -eq [string]$sharePointSp.id -and
                $sharePointRoleIdToValue.ContainsKey($appRoleId)
            ) {
                [void]$sharePointAppRoleList.Add(
                    $sharePointRoleIdToValue[$appRoleId]
                )
            }
        }

        $graphAppGranted = @(
            ConvertTo-NormalizedStringArray `
                -Value $graphAppRoleList.ToArray()
        )

        $sharePointAppGranted = @(
            ConvertTo-NormalizedStringArray `
                -Value $sharePointAppRoleList.ToArray()
        )

        $graphDelegatedGranted = @()
        $sharePointDelegatedGranted = @()

        $delegatedDetectionStatus = 'Pass'
        $delegatedDetectionNote = ''

        try {
            $delegatedUri =
                'https://graph.microsoft.com/v1.0/servicePrincipals/' +
                $workerSp.id +
                '/oauth2PermissionGrants' +
                '?$select=id,clientId,consentType,principalId,resourceId,scope'

            $delegatedGrants = @(
                Get-GraphCollection -Uri $delegatedUri
            )

            $graphDelegatedList =
                New-Object System.Collections.Generic.List[string]

            $sharePointDelegatedList =
                New-Object System.Collections.Generic.List[string]

            foreach ($grant in $delegatedGrants) {
                if (Test-LocalBlank $grant.scope) {
                    continue
                }

                $scopes = @(
                    ([string]$grant.scope) -split '\s+' |
                        Where-Object {
                            -not (Test-LocalBlank $_)
                        }
                )

                if ([string]$grant.resourceId -eq [string]$graphSp.id) {
                    foreach ($scope in $scopes) {
                        [void]$graphDelegatedList.Add($scope)
                    }
                }
                elseif (
                    [string]$grant.resourceId -eq
                    [string]$sharePointSp.id
                ) {
                    foreach ($scope in $scopes) {
                        [void]$sharePointDelegatedList.Add($scope)
                    }
                }
            }

            $graphDelegatedGranted = @(
                ConvertTo-NormalizedStringArray `
                    -Value $graphDelegatedList.ToArray()
            )

            $sharePointDelegatedGranted = @(
                ConvertTo-NormalizedStringArray `
                    -Value $sharePointDelegatedList.ToArray()
            )
        }
        catch {
            $delegatedDetectionStatus = 'Skipped'

            $delegatedDetectionNote = (
                'Delegated permission detection was skipped. Error: ' +
                $_.Exception.Message
            )
        }

        $graphAppMissing = @(
            Get-MissingValues `
                -Granted $graphAppGranted `
                -Required $requiredGraphApp
        )

        $graphDelegatedMissing = @(
            Get-MissingValues `
                -Granted $graphDelegatedGranted `
                -Required $requiredGraphDelegated
        )

        $sharePointAppMissing = @(
            Get-MissingValues `
                -Granted $sharePointAppGranted `
                -Required $requiredSharePointApp
        )

        $sharePointDelegatedMissing = @(
            Get-MissingValues `
                -Granted $sharePointDelegatedGranted `
                -Required $requiredSharePointDelegated
        )

        $graphSelectedAppGranted = @(
            $graphAppGranted |
                Where-Object {
                    $_ -in $SupportedGraphSelectedPermissions
                } |
                Sort-Object -Unique
        )

        $sharePointRestAppGranted = @(
            $sharePointAppGranted |
                Where-Object {
                    $_ -in $SupportedSharePointRestPermissions
                } |
                Sort-Object -Unique
        )

        $hasGraphSitesSelected = (
            $graphAppGranted -contains 'Sites.Selected'
        )

        $hasSharePointSitesSelected = (
            $sharePointAppGranted -contains 'Sites.Selected'
        )

        $graphApplicationRequirementsSatisfied = (
            $graphAppMissing.Count -eq 0
        )

        $graphDelegatedRequirementsSatisfied = (
            $graphDelegatedMissing.Count -eq 0 -and
            (
                $requiredGraphDelegated.Count -eq 0 -or
                $delegatedDetectionStatus -eq 'Pass'
            )
        )

        $graphRequirementsSatisfied = (
            $graphApplicationRequirementsSatisfied -and
            $graphDelegatedRequirementsSatisfied
        )

        $sharePointApplicationRequirementsSatisfied = (
            $sharePointAppMissing.Count -eq 0
        )

        $sharePointDelegatedRequirementsSatisfied = (
            $sharePointDelegatedMissing.Count -eq 0 -and
            (
                $requiredSharePointDelegated.Count -eq 0 -or
                $delegatedDetectionStatus -eq 'Pass'
            )
        )

        $sharePointRequirementsSatisfied = (
            $sharePointApplicationRequirementsSatisfied -and
            $sharePointDelegatedRequirementsSatisfied
        )

        if ($anyRequirementsSpecified) {

            $graphApiUsable = (
                $graphRequirementsSpecified -and
                $graphRequirementsSatisfied
            )

            $sharePointRestApiUsable = (
                $sharePointRequirementsSpecified -and
                $sharePointRequirementsSatisfied
            )
        }
        else {

            $graphApiUsable = (
                $graphSelectedAppGranted.Count -gt 0
            )

            $sharePointRestApiUsable = (
                $sharePointRestAppGranted.Count -gt 0
            )
        }

        $anyApiUsable = (
            $graphApiUsable -or
            $sharePointRestApiUsable
        )

        if ($anyRequirementsSpecified) {
            $requestedPermissionsSatisfied = $anyApiUsable
        }
        else {
            $requestedPermissionsSatisfied = $true
        }

        $bothApisMissingSitesSelected = (
            -not $hasGraphSitesSelected -and
            -not $hasSharePointSitesSelected
        )

        $status = if ($anyApiUsable) {
            'Pass'
        }
        else {
            'Fail'
        }

        return [pscustomobject]@{
            Status = $status
            Error  = ''

            Worker = [pscustomobject]@{
                ServicePrincipalId = [string]$workerSp.id
                AppId              = [string]$workerSp.appId
                DisplayName        = [string]$workerSp.displayName
            }

            Graph = [pscustomobject]@{
                ServicePrincipalId = [string]$graphSp.id
                ServicePrincipalAppId = [string]$graphSp.appId
                ServicePrincipalDisplayName = [string]$graphSp.displayName

                Application = [pscustomobject]@{
                    GrantedRoles  = $graphAppGranted
                    RequiredRoles = $requiredGraphApp
                    MissingRoles  = $graphAppMissing
                }

                Delegated = [pscustomobject]@{
                    GrantedScopes   = $graphDelegatedGranted
                    RequiredScopes  = $requiredGraphDelegated
                    MissingScopes   = $graphDelegatedMissing
                    DetectionStatus = $delegatedDetectionStatus
                    Note            = $delegatedDetectionNote
                }

                SupportedSelectedPermissions = @(
                    $SupportedGraphSelectedPermissions
                )

                SelectedApplicationRolesGranted = $graphSelectedAppGranted
                HasSitesSelectedApplicationRole = $hasGraphSitesSelected
                RequirementsSpecified = $graphRequirementsSpecified
                RequirementsSatisfied = $graphRequirementsSatisfied

                IsUsableForSelectedApplicationAccess = $graphApiUsable
            }

            SharePoint = [pscustomobject]@{
                ServicePrincipalId = [string]$sharePointSp.id
                ServicePrincipalAppId = [string]$sharePointSp.appId
                ServicePrincipalDisplayName = (
                    [string]$sharePointSp.displayName
                )

                Application = [pscustomobject]@{
                    GrantedRoles  = $sharePointAppGranted
                    RequiredRoles = $requiredSharePointApp
                    MissingRoles  = $sharePointAppMissing
                }

                Delegated = [pscustomobject]@{
                    GrantedScopes   = $sharePointDelegatedGranted
                    RequiredScopes  = $requiredSharePointDelegated
                    MissingScopes   = $sharePointDelegatedMissing
                    DetectionStatus = $delegatedDetectionStatus
                    Note            = $delegatedDetectionNote
                }

                SupportedRestPermissions = @(
                    $SupportedSharePointRestPermissions
                )

                RestApplicationRolesGranted = $sharePointRestAppGranted
                HasSitesSelectedApplicationRole = $hasSharePointSitesSelected
                RequirementsSpecified = $sharePointRequirementsSpecified
                RequirementsSatisfied = $sharePointRequirementsSatisfied

                IsUsableForSelectedApplicationAccess = (
                    $sharePointRestApiUsable
                )
            }

            Summary = [pscustomobject]@{
                WorkerAppId = $WorkerAppId
                EvaluationMode = $evaluationMode

                GraphRequirementsSpecified = $graphRequirementsSpecified
                SharePointRequirementsSpecified = (
                    $sharePointRequirementsSpecified
                )
                AnyRequirementsSpecified = $anyRequirementsSpecified

                EffectiveApplicationRoles = @(
                    @($graphAppGranted) +
                    @($sharePointAppGranted) |
                        Sort-Object -Unique
                )

                EffectiveDelegatedScopes = @(
                    @($graphDelegatedGranted) +
                    @($sharePointDelegatedGranted) |
                        Sort-Object -Unique
                )

                GraphApiUsable = $graphApiUsable
                SharePointRestApiUsable = $sharePointRestApiUsable
                AnyApplicationApiUsable = $anyApiUsable

                HasGraphSitesSelectedAppRole = $hasGraphSitesSelected
                HasSharePointSitesSelectedAppRole = (
                    $hasSharePointSitesSelected
                )
                BothApisMissingSitesSelected = (
                    $bothApisMissingSitesSelected
                )

                RequestedPermissionsSatisfied = (
                    $requestedPermissionsSatisfied
                )
            }
        }
    }
    catch {
        return New-FailedValidationResult `
            -ErrorMessage $_.Exception.ToString()
    }
}


try {
    Import-Module Microsoft.Graph.Sites -RequiredVersion 2.33.0 -ErrorAction Stop | Out-Null

    Connect-MgGraph -ClientId $ClientId -TenantId $TenantId -CertificateThumbprint $Thumbprint -NoWelcome | Out-Null

    if (-not $SitePath.StartsWith('/')) { $SitePath = '/' + $SitePath }

    $siteUri = 'https://graph.microsoft.com/v1.0/sites/{0}:{1}:?$select=id,webUrl' -f $TenantHost, $SitePath
    $site    = Invoke-MgGraphRequest -Method GET -Uri $siteUri
    $siteId  = $site.id
    $siteWebUrl = $site.webUrl

    $permsUri = 'https://graph.microsoft.com/v1.0/sites/{0}/permissions' -f $siteId

    $canListPermissions = $true
    $perms = @()
    $listError = ''

    try {
        $permResp = Invoke-MgGraphRequest -Method GET -Uri $permsUri
        $perms    = @($permResp.value)
    }
    catch {
        $canListPermissions = $false
        $listError = $_.Exception.ToString()
        $perms = @()
    }

    $appId    = $TargetAppId.ToString()
    $pmatches = if ($canListPermissions) { _GetAppPermissionMatches -permissions $perms -appId $appId } else { @() }

    $best = $null
    if ($pmatches.Count -gt 0) {
        $best = $pmatches | Sort-Object { _RoleRank $_.roles } -Descending | Select-Object -First 1
    }

    $Changed = $false
    $Deleted = $false
    $Granted = $false
    $ActualRole = ''
    $permissionIds = @()

    if ($best) {
        $Granted = $true
        $ActualRole = (@($best.roles) | Select-Object -First 1)
        $permissionIds = $pmatches | ForEach-Object { $_.id }
    }

    switch ($Mode) {
        'read' {

            $requiredScopes = @('Sites.Selected')

            $validation = Test-WorkerAppPermissions_ExtendedWithDelegated -WorkerAppId $TargetAppId

            $listError = $validation | ConvertTo-Json -Compress -Depth 10
        }

        'grant' {
            if ($CleanDuplicates -and $canListPermissions -and $pmatches.Count -gt 1 -and $best) {
                $toRemove = $pmatches | Where-Object { $_.id -ne $best.id }
                foreach ($p in $toRemove) {
                    Invoke-MgGraphRequest -Method DELETE -Uri ('{0}/{1}' -f $permsUri, $p.id) | Out-Null
                    $Deleted = $true
                }

                $permResp = Invoke-MgGraphRequest -Method GET -Uri $permsUri
                $perms    = @($permResp.value)
                $pmatches = _GetAppPermissionMatches -permissions $perms -appId $appId
                $best     = $null
                if ($pmatches.Count -gt 0) { $best = $pmatches | Sort-Object { _RoleRank $_.roles } -Descending | Select-Object -First 1 }
                $Granted  = ($null -ne $best)
                $ActualRole = if ($best) { (@($best.roles) | Select-Object -First 1) } else { '' }
                $permissionIds = if ($pmatches) { $pmatches | ForEach-Object { $_.id } } else { @() }
            }
            
            $alreadySatisfies = $false
            if ($best -and (_Satisfies -existingRoles $best.roles -requestedRole $Role)) {
                $alreadySatisfies = $true
            }

            if ($alreadySatisfies -and (-not $ForceUpgrade)) {
            }
            else {
                if ($ForceUpgrade -and $canListPermissions -and $pmatches.Count -gt 0) {
                    foreach ($p in $pmatches) {
                        Invoke-MgGraphRequest -Method DELETE -Uri ('{0}/{1}' -f $permsUri, $p.id) | Out-Null
                        $Deleted = $true
                    }
                }
                
                $bodyObj = @{
                    roles = @($Role)
                    grantedToIdentities = @(
                        @{
                            application = @{
                                id = $TargetAppId
                                displayName = "TargetApp"
                            }
                        }
                    )
                }
                
                try {
                    Invoke-MgGraphRequest -Method POST -Uri $permsUri -Body $bodyObj -ContentType 'application/json' | Out-Null
                    $Changed = $true
                }
                catch {
                    $details = $null
                    if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $details = $_.ErrorDetails.Message }
                    throw ("POST failed. permsUri={0}`nErrorDetails={1}`nException={2}" -f $permsUri, $details, $_.Exception.ToString())
                }
                                
            
                if ($canListPermissions) {
                    $permResp = Invoke-MgGraphRequest -Method GET -Uri $permsUri
                    $perms    = @($permResp.value)
                    $pmatches = _GetAppPermissionMatches -permissions $perms -appId $appId
                    $best     = $pmatches | Sort-Object { _RoleRank $_.roles } -Descending | Select-Object -First 1
                    $Granted  = ($null -ne $best)
                    $ActualRole = if ($best) { (@($best.roles) | Select-Object -First 1) } else { '' }
                    $permissionIds = if ($pmatches) { $pmatches | ForEach-Object { $_.id } } else { @() }
                }
                else {
                    $Granted = $true
                    $ActualRole = $Role
                }
                
            }
            
        }

        'delete' {
            if (-not $canListPermissions) {
                throw "Cannot delete without listing existing permission IDs. GET /sites/{siteId}/permissions failed: $listError"
            }

            if ($pmatches.Count -gt 0) {
                foreach ($p in $pmatches) {
                    Invoke-MgGraphRequest -Method DELETE -Uri ('{0}/{1}' -f $permsUri, $p.id) | Out-Null
                    $Deleted = $true
                }
                $Changed = $Deleted

                $permResp = Invoke-MgGraphRequest -Method GET -Uri $permsUri
                $perms    = @($permResp.value)
                $pmatches = _GetAppPermissionMatches -permissions $perms -appId $appId
                $best     = $null
                if ($pmatches.Count -gt 0) { $best = $pmatches | Sort-Object { _RoleRank $_.roles } -Descending | Select-Object -First 1 }
                $Granted  = ($null -ne $best)
                $ActualRole = if ($best) { (@($best.roles) | Select-Object -First 1) } else { '' }
                $permissionIds = if ($pmatches) { $pmatches | ForEach-Object { $_.id } } else { @() }
            }
        }
    }

    $result = @{
        Success = $true
        Data    = @{
            Mode            = $Mode
            TenantHost      = $TenantHost
            SitePath        = $SitePath

            ContainerName   = $ContainerName
            FolderPath      = $FolderPath

            SiteId          = $siteId
            SiteWebUrl      = $siteWebUrl

            TargetAppId     = $appId
            Granted         = $Granted
            Role            = $ActualRole
            PermissionIds   = ($permissionIds -join ',')

            RequestedRole   = $Role
            Changed         = $Changed
            Deleted         = $Deleted

            CanListPermissions = $canListPermissions
            ListError          = $listError
        }
    }
}
catch {
    $result = @{
        Success = $false
        Error   = $_.Exception.ToString()
    }
}

Write-Output ('__JSON__' + ($result | ConvertTo-Json -Compress -Depth 10))