Skip to content

Instantly share code, notes, and snippets.

@kkazala
Last active August 26, 2024 10:59
Show Gist options
  • Save kkazala/399c03e78a8b40f9fecdccead265160c to your computer and use it in GitHub Desktop.
Save kkazala/399c03e78a8b40f9fecdccead265160c to your computer and use it in GitHub Desktop.
Remove unused API permissions assigned to the "SharePoint Online Client Extensibility Web Application Principal". Can be used in Azure Automation or interactively.
<#
.DESCRIPTION
To enhance your tenant's security posture, it's crucial to regularly review the API permissions requested
by SPFx solutions and compare them with those granted to the
"SharePoint Online Client Extensibility Web Application Principal".
IMPORTANT:
To execute the script interactively, use the `-Interactive` flag.
When using this script in Azure Runbook/Azure Function, make sure you enable System-assigned Managed Identity
and grant all necessary API Permissions.
See 'Grant Managed Identity permissions to audit and cleanup "SharePoint Online Client Extensibility Web Application Principal" API permissions': https://gist.github.com/kkazala/e293910545bbf02017a81a847aee9ddb.
To only print the API Permissions that should be removed, use the `-WhatIf` flag.
or used in Azure Automation.
To analyzes tenant-level and site-level app catalogs and extracts API Permissions requested by SPFx solutions, see
"Export-SPFxApiPermissions.ps1": https://gist.github.com/kkazala/b2db6633d59e52560d7fde0db102b446
.NOTES
AUTHOR: Kinga Kazala
LASTEDIT: Aug 26, 2024
#>
Param(
[Parameter (Mandatory = $true)]
[String] $tenantName = "your-tenant-name",
[Parameter (Mandatory = $true)]
[String] $appCatalogSiteName = "appcatalog",
[Parameter (Mandatory = $true)]
[switch]$WhatIf = $false,
[switch]$Interactive = $false
)
#####################################
# The following PS modules must be added to the Azure Runbook
# Microsoft.Graph.Authentication
# Microsoft.Graph.Applications
# Microsoft.Graph.Identity.SignIns
#
#####################################
$Global:SPOAppName = "SharePoint Online Client Extensibility Web Application Principal"
<#
.DESCRIPTION
The Get-GrantedAPIPermissions function retrieves permissions ASSIGNED to the "SharePoint Online Client Extensibility Web Application Principal" service principal
Permissions required: 'Application.Read.All'
.OUTPUTS
API permissions assigned to the "SharePoint Online Client Extensibility Web Application Principal" for application and delegated modes
* Application mode API permissions are NOT supported for this principal
#####################################
#>
function Get-GrantedAPIPermissions {
param(
)
Try {
#####################################
# Get API Permissions for SharePoint Online Client Extensibility Web Application Principal
#####################################
$servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$($Global:SPOAppName)'"
#Delegated permission grants authorizing this service principal to access an API on behalf of a signed-in user.
$permissions = Get-MgServicePrincipalOauth2PermissionGrant -ServicePrincipalId $servicePrincipal.Id
$permissionsDelegated = $permissions | ForEach-Object {
# retrieve delegated permissions of the resource service principal
$resource = Get-MgServicePrincipal -ServicePrincipalId $_.ResourceId
[PSCustomObject]@{
Scope = $_.Scope
ResourceId = $_.ResourceId
Resource = $resource.DisplayName
AllUsers = $_.ConsentType -eq "AllPrincipals"
}
}
#this is NOT supported for the "SharePoint Online Client Extensibility Web Application Principal". Result should always be empty
$permissionsApplication = get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $servicePrincipal.Id
If ($null -ne $permissionsApplication) {
Write-Warning ("Assigning Application permissions to the 'SharePoint Online Client Extensibility Web Application Principal' is NOT SUPPORTED") -ForegroundColor Red
}
@{
delegated = $permissionsDelegated
application = $permissionsApplication
}
}
Catch {
Write-Error "Error downloading API Permissions information: $($_.Exception.Message)"
}
}
<#
.DESCRIPTION
The Revoke-SelectedAPIPermissio function removes unused API permissions assgined to the
"SharePoint Online Client Extensibility Web Application Principal".
Permissions required: 'DelegatedPermissionGrant.ReadWrite.All'
#>
function Revoke-SelectedAPIPermissions {
param(
[System.Object] $delegatedPermissionsByScopeUsage ,
[bool] $whatIf
)
$servicePrincipal = Get-MgServicePrincipal -Filter "DisplayName eq '$($Global:SPOAppName)'"
$permissions = Get-MgOauth2PermissionGrant -Filter "ClientId eq '$($servicePrincipal.Id)' and ConsentType eq 'AllPrincipals'"
$delegatedPermissionsByScopeUsage | Group-Object -Property ResourceId | ForEach-Object {
$Grouped = $_
$OAuth2PermissionGrant = $permissions | Where-Object { $_.ResourceId -eq $Grouped.Name }
$OAuth2PermissionGrantId = $OAuth2PermissionGrant.id
$required = ($Grouped.Group | Where-Object { $null -ne $_.spfxName }).Scope
$notRequired = ($Grouped.Group | Where-Object { $null -eq $_.spfxName }).Scope
if ( $null -ne $required -and $null -ne $notRequired) {
"Updating API Permissions for $( $Grouped.Group[0].Resource). Removing $( $notRequired -join ', ')"
$params = @{
Scope = $required -join " "
}
if ($whatIf) {
"*** Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $OAuth2PermissionGrantId -BodyParameter `$params"
"*** With parameters:"
$params | ConvertTo-Json
}
else {
Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $OAuth2PermissionGrantId -BodyParameter $params
}
}
elseif ( $null -eq $required -and $null -ne $notRequired) {
"Deleting all API Permissions for $( $Grouped.Group[0].Resource)."
if ($whatIf) {
"*** Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $OAuth2PermissionGrantId"
}
else {
Remove-MgOauth2PermissionGrant -OAuth2PermissionGrantId $OAuth2PermissionGrantId
}
}
}
}
<#
.DESCRIPTION
The Get-SPFxAPIPermissions function retrieves permissions REQUESTED by SPFx solutions
It audits tenant-level and site-level app catalogs
Permissions required:
API permissions 'Sites.Selected'
read access must be assigned on the level of each site audited (tenant- and site level)
#>
function Get-SPFxAPIPermissions {
param(
[string]$domainName,
[string]$appcatalog
)
<#
Function Get-SitesWithAppCatalog returns url part after /sites/ for each site with an app catalog
#>
function Get-SitesWithAppCatalog {
param(
[string]$domainName,
[string]$appcatalog
)
$listTitle = "Site Collection App Catalogs"
$expandQuery = '$expand=fields($select=id,sitecollectionurl)'
$listItems = Invoke-MgGraphRequest -Method GET `
"https://graph.microsoft.com/v1.0/sites/$($domainName):/sites/$($appcatalog):/lists/$($listTitle)/items?$expandQuery"
$siteAppCatalogs = $listItems.value | ForEach-Object {
$url = $_.fields.SiteCollectionUrl
$res = $url -match "sites/(?<siteName>.*)"
if ($res) {
$matches["siteName"]
}
}
$siteAppCatalogs += $appcatalog
$siteAppCatalogs
}
function Get-SPFSolutionsAndAPIPermissions {
param(
[string]$domainName,
[string[]]$siteUrls
)
$siteUrls | ForEach-Object {
$siteUrl = $_
$ErrorActionPreference = 'SilentlyContinue'
$listTitle = "Apps for SharePoint"
$expandQuery = '$expand=fields($select=FileLeafRef, AppVersion,WebApiPermissionScopesNote,Title)'
$listItems = Invoke-MgGraphRequest -Method GET `
"https://graph.microsoft.com/v1.0/sites/$($domainName):/sites/$($siteUrl):/lists/$($listTitle)/items?$expandQuery"
$listItems.value | ForEach-Object {
if ($null -ne $_ -and $null -ne $_.fields) {
[PSCustomObject]@{
siteURL = "https://$domainName/sites/$siteUrl"
fileName = $_.fields.FileLeafRef
version = "v$($_.fields.AppVersion)"
apiPermissions = $_.fields.WebApiPermissionScopesNote
title = $_.fields.Title
Error = ""
}
}
else {
[PSCustomObject]@{
siteURL = "https://$domainName/sites/$siteUrl"
fileName = ""
version = ""
apiPermissions = ""
title = ""
Error = "Site not found or access denied."
}
}
}
}
}
$siteUrls = Get-SitesWithAppCatalog -domainName $domainName -appcatalog $appcatalog
Get-SPFSolutionsAndAPIPermissions -domainName $domainName -siteUrls $siteUrls
}
<#
.DESCRIPTION
The Get-Usage function analyzes API permissions requested by SPFx solutions and
the API Permissions assigned to the SharePoint Online Client Extensibility Web Application Principal.
It groups the API permissions by Resource and Scope, and extends the assigned API permissions information
with a reference to SPFx solutions that use them.
.OUTPUTS
spfxPermissionsByScope: value of the spfxPermissionsRequested parameter, grouped by Resource and Scope
delegatedPermissionsByScopeUsage: value of the apiPermissionsGranted parameter, grouped by Resource and Scope and with a reference to SPFx solutions that use the permissions
#>
function Get-Usage {
param(
[System.Object]$apiPermissionsGranted,
[System.Object]$spfxPermissionsRequested
)
function Get-SpfxPermissionsByScope {
param(
[System.Object]$spfxPermissions
)
$arr = @()
$spfxPermissions | ForEach-Object {
$fileName = $_.fileName
$title = $_.title
$_.apiPermissions -split "; " | Sort-Object | Get-Unique | ForEach-Object {
$resource, $scope = $_.Trim() -split ", "
$item = $arr | Where-Object { $_.Resource -eq $resource -and $_.Scope -eq $scope }
if ($null -eq $item) {
$arr += [PSCustomObject]@{
Resource = $resource
Scope = $scope
fileName = @($fileName)
title = @($title)
}
}
else {
If ($item.fileName -notcontains $fileName) {
$item.fileName += $fileName
$item.title += $title
}
}
}
}
$arr
}
function Get-GrantedPermissionsByScope {
param(
[System.Object] $apiPermissionsDelegated
)
$apiPermissionsDelegated | ForEach-Object {
$item = $_
$_.Scope -split " " | ForEach-Object {
[PSCustomObject]@{
Resource = $item.Resource
ResourceId = $item.ResourceId
Scope = $_
AllUsers = $item.AllUsers
}
}
}
}
function Join-Arrays {
param (
[Parameter(Mandatory = $true)] [array]$Left,
[Parameter(Mandatory = $true)] [array]$Right
)
foreach ($leftItem in $Left) {
$match = $Right | Where-Object { $leftItem.Resource -eq $_.Resource -and $leftItem.Scope -eq $_.Scope }
[PSCustomObject]@{
Resource = $leftItem.Resource
ResourceId = $leftItem.ResourceId
Scope = $leftItem.Scope
AllUsers = $leftItem.AllUsers
spfxName = $match.fileName
SolutionName = $match.title
}
}
}
# | Scope | Permission | array(fileName) | array(title) |
$spfxPermissionsByScope = Get-SpfxPermissionsByScope -spfxPermissions ( $spfxPermissionsRequested | Where-Object { $null -ne $_.apiPermissions } )
$delegatedPermissionsByScope = Get-GrantedPermissionsByScope -apiPermissionsDelegated $apiPermissionsGranted.delegated
$delegatedPermissionsByScopeUsage = Join-Arrays -Left $delegatedPermissionsByScope -Right $spfxPermissionsByScope
@{
spfxPermissionsByScope = $spfxPermissionsByScope
delegatedPermissionsByScopeUsage = $delegatedPermissionsByScopeUsage
}
}
<#
.DESCRIPTION
The Invoke-CleanupAPIPermissions removes unused API permissions assigned to the
"SharePoint Online Client Extensibility Web Application Principal".
It retrieves permissions assigned to the SPO service principal, compares them with API permissions requested by
SPFx solutions installed in tenant- and site-level app catalogs, and removes any API permissions that are assigned to the SPO
principal but not explicitely requested by any of the SPFx solutions.
#>
function Invoke-CleanupAPIPermissions {
Param(
[Parameter (Mandatory = $true)]
[string]$tenantName,
[string] $appCatalogSiteName ,
[bool] $whatIf ,
[bool]$interactive = $false
)
If ($interactive) {
Connect-MgGraph -Scopes "Sites.Selected", "Application.Read.All", "DelegatedPermissionGrant.ReadWrite.All"
}
else {
#####################################
# If MSI is used, two environment variables MSI_ENDPOINT and MSI_SECRET are available
#####################################
if ($null -eq $env:MSI_ENDPOINT) {
Write-Error "To execute this script, please enable Managed Identity for this automation, and grant all required API permissions"
return
}
Connect-MgGraph -Identity -NoWelcome
}
"Connected"
# Get API permissions granted to the SPO service principal
$apiPermissionsGranted = Get-GrantedAPIPermissions
# Get API permissions requested by SPFx solutions
# Important: if the Managed Identity doesn't have access to a site (e.g. site-level app catalog, the results will be incomplete)
$spfxPermissionsRequested = Get-SPFxAPIPermissions -domainName "$tenantName.sharepoint.com" -appcatalog $appCatalogSiteName
# compare assigned and requested API permissions
$apiUsageInfo = Get-Usage -apiPermissionsGranted $apiPermissionsGranted -spfxPermissionsRequested $spfxPermissionsRequested
# remove unused API permissions assignments
Revoke-SelectedAPIPermissions -delegatedPermissionsByScopeUsage $apiUsageInfo.delegatedPermissionsByScopeUsage -whatIf $whatIf
}
# Main runbook content
Invoke-CleanupAPIPermissions -tenantName $tenantName -appCatalogSiteName $appCatalogSiteName -whatIf $WhatIf.IsPresent -interactive $Interactive.IsPresent
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment