Building a Self-Service SharePoint Permission Checker

If you run an M365 environment, you know the tickets: “Who has access to this site?” and “Does this person have access to that folder?” They arrive constantly, and answering them by hand in the SharePoint UI is slow — worse, the UI hides the real story, because access often flows through nested security groups that the “Check Permissions” button won’t fully expand.

So I built a self-service tool that answers both questions in under a minute, works across sites/subsites/OneDrive/folders/files, and audits every query.

Note: Every identifier in this post is a placeholder. Swap in your own tenant values before using anything here.


What it does

  • Two modes:
    • Reverse — “Does user X have access to Y?” → a clear ACCESS GRANTED / NO ACCESS verdict plus the effective permission level.
    • Forward — “Who has access to Y?” → a list of every principal with access.
  • Works on SharePoint sites, subsites, OneDrive, folders, files, and :f:/r/ share links.
  • Shows effective permissions, the source of access, and can expand security-group members.
  • Audits every query (who ran it, what they checked, when, and the result).

Architecture

The interesting part isn’t the enumeration — it’s the handoff. Power Automate flows called from Power Apps have a ~120-second synchronous limit. But a runbook can easily take longer than that (cold starts, large libraries). If the app waits synchronously, it times out.

Key insight: Don’t make the app wait. The Start flow fires the runbook and returns immediately (~2 s). The runbook does the heavy lifting on its own schedule and writes the result to a SharePoint list. The Power App then reads that result directly from the list via a “Show Results” button. No timers, no polling loops, no 120-second timeout.

Solution architecture — the Start flow returns in ~2 s; the runbook writes to the audit list; the app reads it back directly.

Data flow:

  1. User picks a mode, enters a URL (and a UPN for Reverse), and clicks Run check.
  2. The Power App calls the matching Start flow.
  3. The Start flow generates a correlationIdPOSTs the runbook webhook (async), and responds with the correlationId (~2 s).
  4. The runbook connects via certificate (app-only) auth and enumerates permissions.
  5. The runbook writes one row to the audit list — tagged with that correlationId.
  6. The user clicks Show Results; the app reads the row by correlationId.
  7. The app displays the verdict (Reverse) or fills a results gallery (Forward).

Components

ComponentPurpose
Power App (canvas)End-user UI: mode select, inputs, Run check, Show Results, results gallery
Start flow × 2Fire the runbook webhook and return the correlationId (~2 s)
Runbook × 2Reverse (one-user effective check) and Forward (enumerate all principals)
Automation AccountHosts the runbooks on a custom PnP.PowerShell runtime
App registration + certificateApp-only auth with least-privilege Graph/SPO scopes
SharePoint audit listResult store and audit trail
Access groupsControl who can use the app and read the audit list

The audit list schema

ColumnType
RequestorUPNSingle line of text
TargetUPNSingle line of text
TargetURLMultiple lines of text
TargetURLKeySingle line of text (255)
ModeChoice (Reverse, Forward)
HasAccessChoice (True, False, N/A)
EffectivePermMultiple lines of text
AccessSourceMultiple lines of text
PermissionsJsonMultiple lines of text
RowCountNumber
ErrorMessageMultiple lines of text
JobIdSingle line of text (40)
CorrelationIdSingle line of text (50)

Setting up app-only auth

The runbooks authenticate as an app registration using a certificate — no user credentials, and least-privilege scopes.

  1. Create an app registration (e.g. YourAppRegistration).
  2. Add application API permissions and grant admin consent:
    • Microsoft Graph: Group.Read.AllDirectory.Read.All
    • SharePoint: Sites.FullControl.All
  3. Generate a certificate and upload the public key to the app registration.
  4. Add the certificate to the Automation Account certificate store.

A quick self-signed cert for testing:

New-SelfSignedCertificate `
  -Subject "CN=YourAppRegistration" `
  -CertStoreLocation "Cert:\CurrentUser\My" `
  -KeyExportPolicy Exportable -KeySpec Signature `
  -NotAfter (Get-Date).AddYears(2)

The Reverse runbook

It uses EnsureUser + CSOM GetUserEffectivePermissions to get the user’s effective permission mask on the target (site, folder, or file), then walks the site collection admins and SharePoint groups — expanding nested Entra security groups via Graph — to explain why the user has access.

<#
.SYNOPSIS
    Returns effective SharePoint/OneDrive permissions for a given UPN on a
    site, subsite, folder, or file. Writes an audit-log row and returns JSON.

.VERSION
    2.0

.NOTES
    Runtime:    PowerShell 7.2 + PnP.PowerShell 2.12.0
    App reg:    YourAppRegistration
    Cert:       <YOUR_CERT_NAME>
    Audit list: /sites/YourAdminSite/Lists/PermissionQuery-AuditLog
    Author:     Rui Qiu
#>

param(
    [object]$WebhookData,
    [string]$Upn,
    [string]$TargetUrl,
    [string]$RequestorUpn,
    [string]$CorrelationId = ''
)

# ============================================================
#  CONFIGURE THESE FOR YOUR ENVIRONMENT
#  Replace the placeholder values below before running.
# ============================================================
$ScriptVersion = '2.0'
$ErrorActionPreference = 'Stop'

$TenantId  = '<YOUR_TENANT_ID>'
$ClientId  = '<YOUR_APP_CLIENT_ID>'
$CertName  = '<YOUR_CERT_NAME>'
$AuditSite = 'https://yourtenant.sharepoint.com/sites/YourAdminSite'
$AuditList = 'PermissionQuery-AuditLog'

# ---------- diagnostic banner ----------
Write-Output "===== SitePermissionCheck-Reverse v$ScriptVersion ====="
Write-Output "Start (UTC): $((Get-Date).ToUniversalTime().ToString('o'))"
Write-Output "PowerShell:  $($PSVersionTable.PSVersion)"
$pnp = Get-Module -ListAvailable PnP.PowerShell | Select-Object -First 1
if ($pnp) { Write-Output "PnP.PowerShell: $($pnp.Version)" }
else       { Write-Warning "PnP.PowerShell not found on this runtime!" }

# ---------- hydrate params ----------
if ($WebhookData) {
    Write-Output "Invocation: WEBHOOK"
    try {
        $payload       = $WebhookData.RequestBody | ConvertFrom-Json
        $Upn           = $payload.UPN
        $TargetUrl     = $payload.TargetUrl
        $RequestorUpn  = $payload.RequestorUPN
        $CorrelationId = if ($payload.CorrelationId) { [string]$payload.CorrelationId } else { '' }
    } catch {
        throw "Failed to parse webhook body: $($_.Exception.Message)"
    }
} else {
    Write-Output "Invocation: DIRECT"
}

if (-not $Upn)          { throw "Missing required parameter: UPN" }
if (-not $TargetUrl)    { throw "Missing required parameter: TargetUrl" }
if (-not $RequestorUpn) { throw "Missing required parameter: RequestorUPN" }

$Upn           = ($Upn.Trim())           -replace '[\r\n\t]', ''
$TargetUrl     = ($TargetUrl.Trim())     -replace '[\r\n\t]', ''
$RequestorUpn  = ($RequestorUpn.Trim())  -replace '[\r\n\t]', ''
$CorrelationId = ($CorrelationId.Trim()) -replace '[\r\n\t]', ''

Write-Output "Upn:           $Upn"
Write-Output "TargetUrl:     $TargetUrl"
Write-Output "RequestorUpn:  $RequestorUpn"
Write-Output "CorrelationId: $CorrelationId"

# ---------- helpers ----------
function ConvertFrom-UrlEncoded {
    param([string]$Value)
    return [System.Uri]::UnescapeDataString($Value)
}

function Get-SiteUrlFromLink {
    param([string]$Url)
    $normalized = $Url -replace '/:[a-z]+:/r/', '/'

    # OneDrive web UI URL: /my?id=/personal/xxx/...
    if ($normalized -match '^(https://[^/]+)/my\?') {
        $hostPart = $Matches[1]
        if ($normalized -match '[?&]id=([^&]+)') {
            $decodedPath = ConvertFrom-UrlEncoded -Value $Matches[1]
            if ($decodedPath -match '^(/personal/[^/]+)') {
                return $hostPart + $Matches[1]
            }
        }
        throw "This is a OneDrive root link with no folder path. Navigate INTO a folder first, then copy the URL (it should contain 'id=/personal/...')."
    }

    $clean   = ($normalized -split '#')[0]
    $clean   = ($clean -split '\?')[0]
    $decoded = ConvertFrom-UrlEncoded -Value $clean

    # Split site from content at the library-name boundary (subsite-aware)
    if ($decoded -match '^(https://[^/]+/(?:sites|teams)/.+?)/(Shared Documents|Documents)(/|$)') {
        return $Matches[1]
    }
    if ($decoded -match '^(https://[^/]+/personal/[^/]+)/(Documents)(/|$)') {
        return $Matches[1]
    }

    # No library segment -> entire path is a site/subsite URL
    if ($decoded -match '^(https://[^/]+/(?:sites|teams)/.+?)(/(?:SitePages|Forms|Lists|_layouts)/.*)?$') {
        return $Matches[1].TrimEnd('/')
    }
    if ($decoded -match '^(https://[^/]+/personal/[^/?#]+)')  { return $Matches[1] }
    if ($decoded -match '^(https://[^/]+)/?$')                { return $Matches[1] }
    throw "Cannot parse site URL from: $Url"
}

function Get-ServerRelativePath {
    param([string]$Url)
    if ($Url -match '/my\?') {
        if ($Url -match '[?&]id=([^&]+)') {
            return ConvertFrom-UrlEncoded -Value $Matches[1]
        }
    }
    $normalized = $Url -replace '/:[a-z]+:/r/', '/'
    $clean = ($normalized -split '#')[0]
    $clean = ($clean -split '\?')[0]
    $decoded = ConvertFrom-UrlEncoded -Value $clean
    if ($decoded -match '^https://[^/]+(/.*)$') { return $Matches[1] }
    return $null
}

function Get-RelativeToSite {
    param([string]$FullPath, [string]$SitePath)
    if (-not $FullPath -or -not $SitePath) { return '' }
    if ($FullPath.ToLower().StartsWith($SitePath.ToLower())) {
        return $FullPath.Substring($SitePath.Length)
    }
    return $FullPath
}

function Get-CertThumbprint {
    $c = Get-AutomationCertificate -Name $CertName
    if (-not $c) { throw "Cert '$CertName' not found." }
    return $c.Thumbprint
}

function Connect-Site {
    param([string]$Url)
    try { Disconnect-PnPOnline -ErrorAction SilentlyContinue } catch { }
    Connect-PnPOnline -Url $Url -ClientId $ClientId -Tenant $TenantId `
        -Thumbprint (Get-CertThumbprint)
}

function Write-Audit {
    param([hashtable]$Values)
    try {
        Connect-Site -Url $AuditSite
        Add-PnPListItem -List $AuditList -Values $Values | Out-Null
        Write-Output "Audit row written."
    } catch {
        Write-Warning "Audit write failed: $($_.Exception.Message)"
    }
}

function Get-UpnFromClaims {
    param([string]$LoginName)
    if (-not $LoginName) { return '' }
    $last = ($LoginName -split '\|')[-1]
    $clean = ($last -split '_')[0]
    return $clean
}

# ---------- main ----------
$result = [ordered]@{
    upn            = $Upn
    targetUrl      = $TargetUrl
    siteUrl        = $null
    isItemLevel    = $false
    hasAccess      = $false
    effectivePerm  = 'None'
    accessSource   = @()
    checkedAt      = (Get-Date).ToUniversalTime().ToString('o')
    scriptVersion  = $ScriptVersion
    correlationId  = $CorrelationId
    error          = $null
}

try {
    $siteUrl        = Get-SiteUrlFromLink -Url $TargetUrl
    $result.siteUrl = $siteUrl
    Write-Output "Resolved site: $siteUrl"

    Connect-Site -Url $siteUrl

    # ---- Determine if item-level ----
    $serverRelPath = Get-ServerRelativePath -Url $TargetUrl
    $siteUri  = [System.Uri]$siteUrl
    $sitePath = $siteUri.AbsolutePath
    $isItemLink = $false
    if ($serverRelPath -and $serverRelPath -ne $sitePath -and $serverRelPath -ne "$sitePath/") {
        $rel = Get-RelativeToSite -FullPath $serverRelPath -SitePath $sitePath
        $relTrim = $rel.TrimEnd('/')
        if ($relTrim -and $relTrim -ne '' -and `
            $relTrim -ne '/Documents' -and `
            $relTrim -ne '/Shared Documents') {
            $isItemLink = $true
        }
    }
    $result.isItemLevel = $isItemLink

    # ---- Effective permissions using CSOM ----
    try {
        $ctx = Get-PnPContext
        $web = $ctx.Web
        $ctx.Load($web)
        $ctx.ExecuteQuery()

        $ensuredUser = $null
        try {
            $ensuredUser = $web.EnsureUser($Upn)
            $ctx.Load($ensuredUser)
            $ctx.ExecuteQuery()
            Write-Output "User ensured: $($ensuredUser.LoginName)"
        } catch {
            Write-Warning "EnsureUser failed for '$Upn': $($_.Exception.Message)"
            $result.effectivePerm = 'User not found in tenant'
            $result.hasAccess = $false
            throw
        }

        $loginName = $ensuredUser.LoginName

        if ($isItemLink) {
            Write-Output "Item-level check requested for: $serverRelPath"
            $file = Get-PnPFile -Url $serverRelPath -AsListItem -ErrorAction SilentlyContinue
            $folder = $null
            if (-not $file) {
                $folder = Get-PnPFolder -Url $serverRelPath -ErrorAction SilentlyContinue
            }

            if ($file) {
                Write-Output "Resolved as file."
                $ctx.Load($file)
                $ctx.ExecuteQuery()
                $permResult = $file.GetUserEffectivePermissions($loginName)
                $ctx.ExecuteQuery()
                $mask = $permResult.Value
            } elseif ($folder) {
                Write-Output "Resolved as folder."
                $folderItem = $folder.ListItemAllFields
                $ctx.Load($folderItem)
                $ctx.ExecuteQuery()
                $permResult = $folderItem.GetUserEffectivePermissions($loginName)
                $ctx.ExecuteQuery()
                $mask = $permResult.Value
            } else {
                Write-Warning "Item not resolvable; falling back to site-level check."
                $permResult = $web.GetUserEffectivePermissions($loginName)
                $ctx.ExecuteQuery()
                $mask = $permResult.Value
            }
        } else {
            $permResult = $web.GetUserEffectivePermissions($loginName)
            $ctx.ExecuteQuery()
            $mask = $permResult.Value
        }

        $permKindType = [Microsoft.SharePoint.Client.PermissionKind]
        $permNames = @()
        if ($mask.Has($permKindType::FullMask))     { $permNames += 'FullControl' }
        if ($mask.Has($permKindType::ManageWeb))     { $permNames += 'ManageWeb' }
        if ($mask.Has($permKindType::EditListItems)) { $permNames += 'Edit' }
        if ($mask.Has($permKindType::AddListItems))  { $permNames += 'Add' }
        if ($mask.Has($permKindType::ViewListItems)) { $permNames += 'View' }
        if ($mask.Has($permKindType::Open))          { $permNames += 'Open' }

        $result.effectivePerm = if ($permNames.Count -gt 0) { $permNames -join ', ' } else { 'None' }
        $result.hasAccess     = $permNames.Count -gt 0
    }
    catch {
        if ($result.effectivePerm -eq 'None') { $result.effectivePerm = 'Error' }
        Write-Warning "Effective permission check failed: $($_.Exception.Message)"
        $result.hasAccess = $false
    }

    # ---- Explain WHY ----
    $sources = @()
    $upnLower = $Upn.ToLower()

    try {
        foreach ($sca in (Get-PnPSiteCollectionAdmin)) {
            $scaId = Get-UpnFromClaims -LoginName $sca.LoginName
            if ($scaId.ToLower() -eq $upnLower) {
                $sources += 'Site Collection Administrator'
            }
        }
    } catch { Write-Warning "SCA enumeration failed: $($_.Exception.Message)" }

    $token = $null
    try { $token = Get-PnPGraphAccessToken } catch { Write-Warning "No Graph token." }

    try {
        foreach ($g in (Get-PnPGroup)) {
            if ($g.Title -match '^SharingLinks\.' -or `
                $g.Title -match '^Limited Access System Group' -or `
                $g.Title -eq 'Everyone' -or `
                $g.Title -eq 'Everyone except external users') {
                continue
            }
            try {
                $members = Get-PnPGroupMember -Identity $g.Title
            } catch { continue }
            foreach ($m in $members) {
                $memberId = Get-UpnFromClaims -LoginName $m.LoginName
                if ($memberId.ToLower() -eq $upnLower) {
                    $sources += "Direct in SP group '$($g.Title)'"
                    continue
                }
                if ($m.PrincipalType -eq 'SecurityGroup' -and $token) {
                    $sgId = Get-UpnFromClaims -LoginName $m.LoginName
                    try {
                        $graphUri = "https://graph.microsoft.com/v1.0/groups/$sgId/transitiveMembers?" + `
                                    '$select=userPrincipalName&$top=999'
                        $r = Invoke-RestMethod -Headers @{ Authorization = "Bearer $token" } -Uri $graphUri
                        if ($r.value.userPrincipalName -contains $Upn) {
                            $sources += "Via Entra SG '$($m.Title)' -> SP group '$($g.Title)'"
                        }
                    } catch { }
                }
            }
        }
    } catch { Write-Warning "SP group walk failed: $($_.Exception.Message)" }

    $result.accessSource = $sources
    Write-Output ("Access sources found: {0}" -f $sources.Count)
}
catch {
    $result.error = $_.Exception.Message
    Write-Warning "Permission check failed: $($_.Exception.Message)"
}

# ---------- audit ----------
$safeUrl    = $TargetUrl
$safeUrlKey = if ($TargetUrl.Length -gt 255) { $TargetUrl.Substring(0, 255) } else { $TargetUrl }

$hasAccessValue = if ($result.error) { 'N/A' } `
                  elseif ($result.hasAccess) { 'True' } `
                  else { 'False' }

$jobIdValue = if ($PSPrivateMetadata.JobId) { $PSPrivateMetadata.JobId.Guid } else { '' }

Write-Audit -Values @{
    Title         = "$RequestorUpn checked $Upn"
    RequestorUPN  = $RequestorUpn
    TargetUPN     = $Upn
    TargetURL     = $safeUrl
    TargetURLKey  = $safeUrlKey
    Mode          = 'Reverse'
    HasAccess     = $hasAccessValue
    EffectivePerm = $result.effectivePerm
    AccessSource  = ($result.accessSource -join '; ')
    ErrorMessage  = $result.error
    JobId         = $jobIdValue
    CorrelationId = $CorrelationId
}

# ---------- output ----------
$json = $result | ConvertTo-Json -Depth 5
Write-Output "===== RESULT JSON ====="
Write-Output $json

The Forward runbook

It enumerates site collection admins, SharePoint groups (with optional transitive member expansion), and broken-inheritance items. It also handles subsites and folder/file-scoped queries.

Performance lesson: The first version checked HasUniqueRoleAssignments with a Get-PnPProperty call per item — that took ~22 minutes on a large library. Switching to a bulk CAML query that returns HasUniqueRoleAssignments for all items in paged batches cut it to ~30 seconds. One call for 500 items beats 500 calls for 500 items.

<#
.SYNOPSIS
    Enumerates permissions on a SharePoint/OneDrive site, subsite, folder, or file.

.VERSION
    2.0

.NOTES
    Runtime:    PowerShell 7.2 + PnP.PowerShell 2.12.0
    App reg:    YourAppRegistration
    Cert:       <YOUR_CERT_NAME>
    Audit list: /sites/YourAdminSite/Lists/PermissionQuery-AuditLog
    Author:    Rui Qiu
#>

param(
    [object]$WebhookData,
    [string]$TargetUrl,
    [string]$RequestorUpn,
    [string]$ExpandGroups = 'false',
    [string]$CorrelationId = ''
)

# ============================================================
#  CONFIGURE THESE FOR YOUR ENVIRONMENT
#  Replace the placeholder values below before running.
# ============================================================
$ScriptVersion = '2.0'
$ErrorActionPreference = 'Stop'

$TenantId  = '<YOUR_TENANT_ID>'
$ClientId  = '<YOUR_APP_CLIENT_ID>'
$CertName  = '<YOUR_CERT_NAME>'
$AuditSite = 'https://yourtenant.sharepoint.com/sites/YourAdminSite'
$AuditList = 'PermissionQuery-AuditLog'
$MaxScan   = 2000
$MaxUnique = 25

# ---------- diagnostic banner ----------
Write-Output "===== SitePermissionCheck-Forward v$ScriptVersion ====="
Write-Output "Start (UTC): $((Get-Date).ToUniversalTime().ToString('o'))"
Write-Output "PowerShell:   $($PSVersionTable.PSVersion)"
$pnp = Get-Module -ListAvailable PnP.PowerShell | Select-Object -First 1
if ($pnp) { Write-Output "PnP.PowerShell: $($pnp.Version)" }
else       { Write-Warning "PnP.PowerShell not found!" }

# ---------- hydrate params ----------
if ($WebhookData) {
    Write-Output "Invocation: WEBHOOK"
    try {
        $payload       = $WebhookData.RequestBody | ConvertFrom-Json
        $TargetUrl     = $payload.TargetUrl
        $RequestorUpn  = $payload.RequestorUPN
        $ExpandGroups  = if ($payload.ExpandGroups) { [string]$payload.ExpandGroups } else { 'false' }
        $CorrelationId = if ($payload.CorrelationId) { [string]$payload.CorrelationId } else { '' }
    } catch {
        throw "Failed to parse webhook body: $($_.Exception.Message)"
    }
} else {
    Write-Output "Invocation: DIRECT"
}

if (-not $TargetUrl)    { throw "Missing required parameter: TargetUrl" }
if (-not $RequestorUpn) { throw "Missing required parameter: RequestorUPN" }

$TargetUrl     = ($TargetUrl.Trim())     -replace '[\r\n\t]', ''
$RequestorUpn  = ($RequestorUpn.Trim())  -replace '[\r\n\t]', ''
$CorrelationId = ($CorrelationId.Trim()) -replace '[\r\n\t]', ''
$doExpand      = $ExpandGroups -eq 'true'

Write-Output "TargetUrl:     $TargetUrl"
Write-Output "RequestorUpn:  $RequestorUpn"
Write-Output "ExpandGroups:  $doExpand"
Write-Output "CorrelationId: $CorrelationId"

# ---------- helpers ----------
function ConvertFrom-UrlEncoded {
    param([string]$Value)
    return [System.Uri]::UnescapeDataString($Value)
}

function Get-SiteUrlFromLink {
    param([string]$Url)
    $normalized = $Url -replace '/:[a-z]+:/r/', '/'

    if ($normalized -match '^(https://[^/]+)/my\?') {
        $hostPart = $Matches[1]
        if ($normalized -match '[?&]id=([^&]+)') {
            $decodedPath = ConvertFrom-UrlEncoded -Value $Matches[1]
            if ($decodedPath -match '^(/personal/[^/]+)') {
                return $hostPart + $Matches[1]
            }
        }
        throw "This is a OneDrive root link with no folder path. Navigate INTO a folder first, then copy the URL (it should contain 'id=/personal/...')."
    }

    $clean   = ($normalized -split '#')[0]
    $clean   = ($clean -split '\?')[0]
    $decoded = ConvertFrom-UrlEncoded -Value $clean

    if ($decoded -match '^(https://[^/]+/(?:sites|teams)/.+?)/(Shared Documents|Documents)(/|$)') {
        return $Matches[1]
    }
    if ($decoded -match '^(https://[^/]+/personal/[^/]+)/(Documents)(/|$)') {
        return $Matches[1]
    }

    if ($decoded -match '^(https://[^/]+/(?:sites|teams)/.+?)(/(?:SitePages|Forms|Lists|_layouts)/.*)?$') {
        return $Matches[1].TrimEnd('/')
    }
    if ($decoded -match '^(https://[^/]+/personal/[^/?#]+)')  { return $Matches[1] }
    if ($decoded -match '^(https://[^/]+)/?$')                { return $Matches[1] }
    throw "Cannot parse site URL from: $Url"
}

function Get-ServerRelativePath {
    param([string]$Url)
    if ($Url -match '/my\?') {
        if ($Url -match '[?&]id=([^&]+)') {
            return ConvertFrom-UrlEncoded -Value $Matches[1]
        }
    }
    $normalized = $Url -replace '/:[a-z]+:/r/', '/'
    $clean = ($normalized -split '#')[0]
    $clean = ($clean -split '\?')[0]
    $decoded = ConvertFrom-UrlEncoded -Value $clean
    if ($decoded -match '^https://[^/]+(/.*)$') { return $Matches[1] }
    return $null
}

function Get-RelativeToSite {
    param([string]$FullPath, [string]$SitePath)
    if (-not $FullPath -or -not $SitePath) { return '' }
    if ($FullPath.ToLower().StartsWith($SitePath.ToLower())) {
        return $FullPath.Substring($SitePath.Length)
    }
    return $FullPath
}

function Get-CertThumbprint {
    $c = Get-AutomationCertificate -Name $CertName
    if (-not $c) { throw "Cert '$CertName' not found." }
    return $c.Thumbprint
}

function Connect-Site {
    param([string]$Url)
    try { Disconnect-PnPOnline -ErrorAction SilentlyContinue } catch { }
    Connect-PnPOnline -Url $Url -ClientId $ClientId -Tenant $TenantId `
        -Thumbprint (Get-CertThumbprint)
}

function Write-Audit {
    param([hashtable]$Values)
    try {
        Connect-Site -Url $AuditSite
        Add-PnPListItem -List $AuditList -Values $Values | Out-Null
        Write-Output "Audit row written."
    } catch {
        Write-Warning "Audit write failed: $($_.Exception.Message)"
    }
}

function Get-PrincipalType {
    param($PrincipalType)
    switch ($PrincipalType) {
        'User'            { return 'User' }
        'SecurityGroup'   { return 'EntraSG' }
        'SharePointGroup' { return 'SPGroup' }
        default           { return 'Unknown' }
    }
}

function Test-SkipGroup {
    param([string]$GroupTitle)
    if ($GroupTitle -match '^SharingLinks\.') { return $true }
    if ($GroupTitle -match '^Limited Access System Group') { return $true }
    if ($GroupTitle -eq 'Everyone') { return $true }
    if ($GroupTitle -eq 'Everyone except external users') { return $true }
    return $false
}

function Test-PureLimitedAccess {
    param([string]$RoleNames)
    if (-not $RoleNames) { return $true }
    if ($RoleNames -match 'Full Control|Edit|Design|Contribute|Read|ManageWeb') { return $false }
    if ($RoleNames -match 'Limited Access') { return $true }
    return $false
}

function Get-CleanGroupId {
    param([string]$LoginName)
    if (-not $LoginName) { return '' }
    $last = ($LoginName -split '\|')[-1]
    $clean = ($last -split '_')[0]
    return $clean
}

function Add-RoleAssignments {
    param($Securable, [string]$SourceLabel, $RowList)
    $ra = Get-PnPProperty -ClientObject $Securable -Property RoleAssignments
    foreach ($assignment in $ra) {
        Get-PnPProperty -ClientObject $assignment -Property Member, RoleDefinitionBindings | Out-Null
        if (Test-SkipGroup -GroupTitle $assignment.Member.Title) { continue }
        $roleNames = ($assignment.RoleDefinitionBindings | ForEach-Object { $_.Name }) -join ', '
        if (Test-PureLimitedAccess -RoleNames $roleNames) { continue }
        $RowList.Add([pscustomobject]@{
            Principal  = $assignment.Member.Title
            LoginName  = $assignment.Member.LoginName
            Type       = Get-PrincipalType -PrincipalType $assignment.Member.PrincipalType
            Permission = $roleNames
            SPGroup    = $null
            Source     = $SourceLabel
            Members    = @()
        })
    }
}

# ---------- main ----------
$rows       = New-Object System.Collections.Generic.List[object]
$errorMsg   = $null
$siteUrl    = $null
$queryScope = 'SITE-WIDE'

try {
    $siteUrl = Get-SiteUrlFromLink -Url $TargetUrl
    Write-Output "Resolved site: $siteUrl"
    Connect-Site -Url $siteUrl

    # ---- Determine scope ----
    $serverRelPath = Get-ServerRelativePath -Url $TargetUrl
    $siteUri  = [System.Uri]$siteUrl
    $sitePath = $siteUri.AbsolutePath
    $isScoped = $false
    $scopedPath = $null

    if ($serverRelPath) {
        $rel = Get-RelativeToSite -FullPath $serverRelPath -SitePath $sitePath
        $relTrim = $rel.TrimEnd('/')
        if ($relTrim -and $relTrim -ne '' -and `
            $relTrim -ne '/Documents' -and `
            $relTrim -ne '/Shared Documents') {
            $isScoped = $true
            $scopedPath = $serverRelPath
        }
    }
    $queryScope = if ($isScoped) { "FOLDER/FILE-SCOPED: $scopedPath" } else { 'SITE-WIDE' }
    Write-Output "Query scope: $queryScope"

    if (-not $isScoped) {
        # ===================== SITE-WIDE =====================

        # --- 1) SCAs ---
        Write-Output "Enumerating SCAs..."
        try {
            foreach ($sca in (Get-PnPSiteCollectionAdmin)) {
                $rows.Add([pscustomobject]@{
                    Principal  = $sca.Title
                    LoginName  = $sca.LoginName
                    Type       = Get-PrincipalType -PrincipalType $sca.PrincipalType
                    Permission = 'Full Control (SCA)'
                    SPGroup    = $null
                    Source     = 'Site Collection Administrator'
                    Members    = @()
                })
            }
            Write-Output "SCAs added: $($rows.Count)"
        } catch { Write-Warning "SCA enumeration failed: $($_.Exception.Message)" }

        # --- 2) SP groups ---
        Write-Output "Enumerating SP groups..."
        $token = $null
        try { $token = Get-PnPGraphAccessToken } catch { Write-Warning "No Graph token." }

        try {
            foreach ($g in (Get-PnPGroup)) {
                if (Test-SkipGroup -GroupTitle $g.Title) { continue }
                $roleName = 'Unknown'
                try {
                    $perms = Get-PnPGroupPermissions -Identity $g.Title
                    if ($perms) { $roleName = ($perms | Select-Object -First 1).Name }
                } catch { }
                $members = @()
                try { $members = Get-PnPGroupMember -Identity $g.Title } catch { continue }
                foreach ($m in $members) {
                    $typeLabel = Get-PrincipalType -PrincipalType $m.PrincipalType
                    $memberList = @()
                    if ($typeLabel -eq 'EntraSG' -and $doExpand -and $token) {
                        $sgId = Get-CleanGroupId -LoginName $m.LoginName
                        try {
                            $graphUri = "https://graph.microsoft.com/v1.0/groups/$sgId/transitiveMembers?" + `
                                        '$select=displayName,userPrincipalName,accountEnabled&$top=999'
                            $r = Invoke-RestMethod -Headers @{ Authorization = "Bearer $token" } -Uri $graphUri
                            $memberList = $r.value | Where-Object userPrincipalName |
                                Select-Object displayName, userPrincipalName, accountEnabled
                        } catch {
                            Write-Warning "Could not expand SG '$($m.Title)': $($_.Exception.Message)"
                        }
                    }
                    $rows.Add([pscustomobject]@{
                        Principal  = $m.Title
                        LoginName  = $m.LoginName
                        Type       = $typeLabel
                        Permission = $roleName
                        SPGroup    = $g.Title
                        Source     = "Member of SP group '$($g.Title)'"
                        Members    = $memberList
                    })
                }
            }
            Write-Output "Total rows after SP group walk: $($rows.Count)"
        } catch { Write-Warning "SP group walk failed: $($_.Exception.Message)" }

        # --- 3) Broken inheritance (bulk CAML) ---
        Write-Output "Checking broken inheritance in Documents (bulk fetch, cap $MaxUnique unique / $MaxScan scanned)..."
        try {
            $list = Get-PnPList -Identity 'Documents' -Includes HasUniqueRoleAssignments -ErrorAction SilentlyContinue
            if ($list -and $list.HasUniqueRoleAssignments) {
                $camlQuery = @"
<View Scope='RecursiveAll'>
  <ViewFields>
    <FieldRef Name='FileLeafRef'/>
    <FieldRef Name='FileRef'/>
    <FieldRef Name='HasUniqueRoleAssignments'/>
  </ViewFields>
  <RowLimit Paged='TRUE'>500</RowLimit>
</View>
"@
                $allItems = Get-PnPListItem -List $list -Query $camlQuery -ErrorAction SilentlyContinue
                $uniqueItems = @()
                $scanned = 0
                foreach ($it in $allItems) {
                    $scanned++
                    if ($scanned -gt $MaxScan) {
                        Write-Warning "Scan cap reached ($MaxScan items). Some unique-perm items may not be listed."
                        break
                    }
                    if ($it.FieldValues.HasUniqueRoleAssignments -eq $true) {
                        $uniqueItems += $it
                        if ($uniqueItems.Count -ge $MaxUnique) { break }
                    }
                }
                Write-Output "Scanned: $scanned items; unique-perm items found: $($uniqueItems.Count)"

                foreach ($it in $uniqueItems) {
                    $leaf = $it.FieldValues.FileLeafRef
                    $ra = Get-PnPProperty -ClientObject $it -Property RoleAssignments
                    foreach ($r in $ra) {
                        Get-PnPProperty -ClientObject $r -Property Member, RoleDefinitionBindings | Out-Null
                        if (Test-SkipGroup -GroupTitle $r.Member.Title) { continue }
                        $roleNames = ($r.RoleDefinitionBindings.Name -join ', ')
                        if (Test-PureLimitedAccess -RoleNames $roleNames) { continue }
                        $rows.Add([pscustomobject]@{
                            Principal  = $r.Member.Title
                            LoginName  = $r.Member.LoginName
                            Type       = Get-PrincipalType -PrincipalType $r.Member.PrincipalType
                            Permission = $roleNames
                            SPGroup    = $null
                            Source     = "Item-level: /$($list.Title)/$leaf"
                            Members    = @()
                        })
                    }
                }
            }
        } catch { Write-Warning "Broken-inheritance walk failed: $($_.Exception.Message)" }

    } else {
        # ===================== FOLDER/FILE-SCOPED =====================
        Write-Output "Enumerating permissions specific to: $scopedPath"
        try {
            $file = Get-PnPFile -Url $scopedPath -AsListItem -ErrorAction SilentlyContinue
            $target = $null
            $targetLabel = ""
            $isFolder = $false

            if ($file) {
                Get-PnPProperty -ClientObject $file -Property HasUniqueRoleAssignments | Out-Null
                $target = $file
                $targetLabel = "File: $scopedPath"
            } else {
                $folder = Get-PnPFolder -Url $scopedPath -ErrorAction SilentlyContinue
                if ($folder) {
                    $folderItem = $folder.ListItemAllFields
                    Get-PnPProperty -ClientObject $folderItem -Property HasUniqueRoleAssignments | Out-Null
                    $target = $folderItem
                    $targetLabel = "Folder: $scopedPath"
                    $isFolder = $true
                }
            }

            if ($target) {
                if ($target.HasUniqueRoleAssignments) {
                    Write-Output "$targetLabel has UNIQUE permissions - enumerating direct grants..."
                    Add-RoleAssignments -Securable $target -SourceLabel "$targetLabel (unique perms)" -RowList $rows
                } else {
                    Write-Output "$targetLabel INHERITS from parent - showing inherited access."
                    $rows.Add([pscustomobject]@{
                        Principal  = '(inherited)'
                        LoginName  = ''
                        Type       = 'Info'
                        Permission = 'Inherits from parent'
                        SPGroup    = $null
                        Source     = "$targetLabel inherits permissions from its parent"
                        Members    = @()
                    })
                    Write-Output "Enumerating inherited (site-level) grants that apply..."
                    $web = Get-PnPWeb
                    Add-RoleAssignments -Securable $web -SourceLabel "Inherited access to $targetLabel" -RowList $rows

                    foreach ($sca in (Get-PnPSiteCollectionAdmin)) {
                        $rows.Add([pscustomobject]@{
                            Principal  = $sca.Title
                            LoginName  = $sca.LoginName
                            Type       = Get-PrincipalType -PrincipalType $sca.PrincipalType
                            Permission = 'Full Control (SCA, inherited)'
                            SPGroup    = $null
                            Source     = "Inherited access to $targetLabel"
                            Members    = @()
                        })
                    }
                }

                if ($isFolder) {
                    Write-Output "Checking immediate children of the folder for unique permissions..."
                    try {
                        $siteRelFolder = Get-RelativeToSite -FullPath $scopedPath -SitePath $sitePath
                        $children = Get-PnPFolderItem -FolderSiteRelativeUrl $siteRelFolder -ErrorAction SilentlyContinue
                        $childCount = 0
                        foreach ($child in $children) {
                            if ($childCount -ge $MaxUnique) { break }
                            try {
                                $childListItem = $child.ListItemAllFields
                                $hasUnique = Get-PnPProperty -ClientObject $childListItem -Property HasUniqueRoleAssignments
                                if ($hasUnique) {
                                    Add-RoleAssignments -Securable $childListItem `
                                        -SourceLabel "Child (unique): $siteRelFolder/$($child.Name)" -RowList $rows
                                    $childCount++
                                }
                            } catch {
                                Write-Warning "Child check failed for $($child.Name): $($_.Exception.Message)"
                            }
                        }
                        Write-Output "Children with unique perms: $childCount"
                    } catch {
                        Write-Warning "Child enumeration failed: $($_.Exception.Message)"
                    }
                }
            } else {
                Write-Warning "Could not resolve $scopedPath as file or folder"
                $errorMsg = "Item not found: $scopedPath"
            }
        } catch {
            Write-Warning "Scoped enumeration failed: $($_.Exception.Message)"
            $errorMsg = $_.Exception.Message
        }
    }
}
catch {
    $errorMsg = $_.Exception.Message
    Write-Warning "Forward lookup failed: $errorMsg"
}

# ---------- audit ----------
$safeUrlKey = if ($TargetUrl.Length -gt 255) { $TargetUrl.Substring(0, 255) } else { $TargetUrl }
$summary    = ($rows | Select-Object -First 5 | ForEach-Object { "$($_.Principal) [$($_.Type)] - $($_.Permission)" }) -join '; '
$jobIdValue = if ($PSPrivateMetadata.JobId) { $PSPrivateMetadata.JobId.Guid } else { '' }

Write-Audit -Values @{
    Title           = "$RequestorUpn forward-lookup on $siteUrl"
    RequestorUPN    = $RequestorUpn
    TargetURL       = $TargetUrl
    TargetURLKey    = $safeUrlKey
    Mode            = 'Forward'
    HasAccess       = 'N/A'
    AccessSource    = $summary
    RowCount        = $rows.Count
    PermissionsJson = ($rows | ConvertTo-Json -Depth 6 -Compress)
    ErrorMessage    = $errorMsg
    JobId           = $jobIdValue
    CorrelationId   = $CorrelationId
}

# ---------- output ----------
$result = @{
    siteUrl       = $siteUrl
    queryScope    = $queryScope
    totalRows     = $rows.Count
    permissions   = $rows
    checkedAt     = (Get-Date).ToUniversalTime().ToString('o')
    scriptVersion = $ScriptVersion
    correlationId = $CorrelationId
    error         = $errorMsg
}
Write-Output "===== RESULT JSON ====="
Write-Output ($result | ConvertTo-Json -Depth 6)

The Start flows (Power Automate)

Each flow is deliberately tiny: a PowerApps (V2) trigger → Compose a GUID (the correlationId) → HTTP POST to the runbook webhook → Respond with the correlationId. No delays, no loops.

Reverse HTTP body:

{
  "UPN": "@{triggerBody()?['text']}",
  "TargetUrl": "@{triggerBody()?['text_1']}",
  "RequestorUPN": "@{triggerBody()?['text_2']}",
  "CorrelationId": "@{outputs('Compose_CorrelationId')}"
}

Forward HTTP body:

{
  "TargetUrl": "@{triggerBody()?['text']}",
  "RequestorUPN": "@{triggerBody()?['text_1']}",
  "ExpandGroups": "@{triggerBody()?['text_2']}",
  "CorrelationId": "@{outputs('Compose_CorrelationId')}"
}

The Respond action returns a single output, correlationid = outputs('Compose_CorrelationId').


The Power App

btnCheck fires the matching Start flow and stores the returned correlationIdbtnShowResults refreshes the audit list, looks up the row by correlationId, and then either shows a verdict (Reverse) or populates a permissions gallery (Forward).

Here’s the Forward branch of Show Results — it parses the PermissionsJson the runbook stored:

Refresh('PermissionQuery-AuditLog');
Set(varRowF, First(Filter('PermissionQuery-AuditLog', CorrelationId = varCorrelationF)));
If(
    IsBlank(varRowF),
    Notify("Still processing - click again in a moment"),
    ClearCollect(colPermissions,
        ForAll(
            Table(ParseJSON(varRowF.PermissionsJson)),
            {
                Principal:  Text(ThisRecord.Value.Principal),
                Type:       Text(ThisRecord.Value.Type),
                Permission: Text(ThisRecord.Value.Permission),
                Source:     Text(ThisRecord.Value.Source)
            }
        )
    )
)

Lessons learned

  • Power Apps Timer controls are unreliable for polling. A manual “Show Results” button + direct list read is simpler and far more robust.
  • The ~120-second synchronous flow limit forces an async pattern for anything backed by runbooks. Fire-and-return, then read the result later.
  • $ctx.Load($obj, 'PropertyName') (string property) doesn’t work in PnP.PowerShell — use Get-PnPProperty instead.
  • Per-item property calls are deadly at scale. Bulk CAML for HasUniqueRoleAssignments turned 22 minutes into 30 seconds.
  • SharePoint URLs are messy. Handle /sites/teams, subsites (split at the library name), OneDrive /personal and /my?id=, and :f:/r/ share links. Reject the bare OneDrive /my?viewid= (no id=) with a friendly message.
  • Direct list reads require the app’s users to have list read access — otherwise it works for you (admin) but silently fails for everyone else.
  • Use a correlationId to match each run to its row — it avoids stale-data mismatches when queries happen back-to-back.

Wrapping up

The result is a tool that answers “who has access?” and “does this person have access?” in under a minute — fully audited, using only app-only certificate auth and least-privilege scopes. The async fire-and-read pattern sidesteps the flow timeout entirely and works no matter how long the runbook takes.


Leave a Comment