Skip to content

Instantly share code, notes, and snippets.

@kkazala
Last active May 30, 2025 13:10
Show Gist options
  • Save kkazala/6b82fb11fc46f371016e2824d4fb4ffb to your computer and use it in GitHub Desktop.
Save kkazala/6b82fb11fc46f371016e2824d4fb4ffb to your computer and use it in GitHub Desktop.
Deploy SPFx app to SharePoint Online site-level app catalog using SharePoint REST API. IMPORTANT: FullControl required on the site collection to add the apps. Np access to tenant-level app catalog required
<#
.DESCRIPTION
This script can be used to grant System-Managed Identity used by automation (Azure Runbook, Azure Functions)
The script uses PnP.PowerShell and Microsoft.Graph.
PnP PowerShell requires PowerShell 7.2 or later.
Other useful tools:
- Microsoft Graph Permissions Explorer: https://graphpermissions.merill.net/permission/
.PARAMETER spId
Object Id of System-Managed Identity.
.PARAMETER tenantName
Tenant name is necessary to connect to the sharepoint site.
.NOTES
AUTHOR: Kinga Kazala
LASTEDIT: 30.05.2025
#>
[CmdletBinding()]
param(
[string]$spId,
[string]$tenantName,
[string]$siteName
)
<#
.DESCRIPTION
The Set-ManagedIdentityAPIPermissions function grants the following Microsoft Graph API permissions:
- 'Sites.Selected'
Other permissions may be added by updating the $permissionMap array.
Use the following commands to retrieve information about additional applications:
Import-Module Microsoft.Graph.Applications
Connect-MgGraph -Scopes 'Application.Read.All'
Get-MgServicePrincipal -Search '"displayName:Team"' -CountVariable CountVar `
-Property "displayName,appId,replyUrls,servicePrincipalType,oauth2PermissionScopes,appRoles,resourceSpecificApplicationPermissions" `
-ConsistencyLevel eventual
See https://learn.microsoft.com/en-us/graph/api/resources/serviceprincipal?view=graph-rest-1.0#properties
for a currnet list of available properties and their descriptions
#>
function Set-APIPermissions {
param(
[string]$spId
)
$graphAppId="00000003-0000-0000-c000-000000000000"
$spoAppId="00000003-0000-0ff1-ce00-000000000000"
$permissionMap = @{
$graphAppId = @( # Microsoft Graph
'Sites.Selected'
)
$spoAppId=@( # SharePoint Online
'Sites.Selected'
)
}
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Applications
Connect-MgGraph -Scopes "AppRoleAssignment.ReadWrite.All" , 'Application.Read.All' -NoWelcome
Get-MgServicePrincipal -All | Where-Object { $_.AppId -in $permissionMap.Keys } -PipelineVariable SP | ForEach-Object {
$SP.AppRoles | Where-Object { $_.Value -in $permissionMap[$SP.AppId] -and $_.AllowedMemberTypes -contains "Application" } -PipelineVariable AppRole | ForEach-Object {
try {
$params = @{
principalId = $spId
resourceId = $SP.Id
appRoleId = $AppRole.Id
}
New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $spId -BodyParameter $params -ErrorAction:SilentlyContinue
}
catch {
throw $_.Exception
}
}
}
}
function Get-APIPermissions{
param(
[string]$spId
)
$appRoleAssignments=Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $spId
$appRoleAssignments | ForEach-Object {
$assignment=$_
$sp = Get-MgServicePrincipal -ServicePrincipalId $assignment.ResourceId
$appRole = $sp.AppRoles | Where-Object { $_.Id -eq $assignment.AppRoleId }
[PSCustomObject]@{
ServicePrincipal = $sp.DisplayName
AppRole = $appRole.DisplayName
Scope = $appRole.Value
}
}
}
<#
.DESCRIPTION
The Set-SiteAppCatalogPermissions function grants Managed Identity Read acess to the following SPO sites:
- root site : this is required for the Azure Runbook to connect to SharePoint and request app catalogs
- tenant level app catalog
- all detected site level app catalogs
#>
function Set-SiteAppCatalogPermissions {
param(
[string]$tenantName,
[string]$siteName,
[string]$spId,
[ValidateSet("read", "write","fullcontrol")]
$appRole = "write" #default role is write, can be changed to read if necessary
)
$sp = Get-MgServicePrincipal -ServicePrincipalId $spId #script will stop if service principal does not exist
$tenantUrl = "${tenantname}.sharepoint.com"
$application = @{
id = $sp.AppId
displayName = $sp.DisplayName
}
Connect-MgGraph -Scope Sites.FullControl.All -NoWelcome
#read access to the tenant App Catalog
New-MgSitePermission -SiteId "${tenantUrl}:/sites/appCatalog:" -Roles "read" -GrantedToIdentities @{ Application = $application }
#write access to the siteApp Catalog
New-MgSitePermission -SiteId "${tenantUrl}:/sites/${siteName}:" -Roles $appRole -GrantedToIdentities @{ Application = $application }
}
function Get-SiteAppCatalogPermissions {
param(
[string]$tenantName,
[string]$siteName,
[string]$spId
)
$sp = Get-MgServicePrincipal -ServicePrincipalId $spId #script will stop if service principal does not exist
$tenantUrl = "${tenantname}.sharepoint.com"
Get-MgSitePermission -SiteId "${tenantUrl}:/sites/${siteName}:" `
| ForEach-Object {
@{
Roles= $_.Roles -join ", "
Application= $_.GrantedToIdentities.Application
}
} `
| Where-Object { $_.Application.Id -eq $sp.AppId } `
| ForEach-Object {
[PSCustomObject]@{
SiteUrl = "https://$tenantUrl/sites/${siteName}"
GrantedTo = $_.Application.DisplayName
GrantedToAppId = $_.Application.Id
Roles = $_.Roles
}
} | Out-Default
}
write-host "`r`nGranting API permissions to the Managed Identity $spId" -ForegroundColor Yellow
Set-APIPermissions -spId $spId
Get-APIPermissions -spId $spId
write-host "`r`nGranting permissions to the Managed Identity $spId on $siteName SharePoint sites" -ForegroundColor Yellow
Set-SiteAppCatalogPermissions -tenantName $tenantName -siteName $siteName -spId $spId -appRole "fullcontrol"
Get-SiteAppCatalogPermissions -tenantName $tenantName -siteName $siteName -spId $spId
[CmdletBinding()]
param(
[string] $tenantName,
[string] $siteName,
[string] $folderPath,
)
Import-Module "$PSScriptRoot/SPFx-DeploySolutions.ps1"
$requestUrlApps = "https://${tenantName}.sharepoint.com/sites/${siteName}/_api/web/sitecollectionappcatalog"
Write-Host "##[group]Who am I"
$azContext = (Get-AzContext).Account.Id
$sp = Get-AzADServicePrincipal -ApplicationId $azContext
Write-Host "##[debug] ServicePrincipal: $($sp.Id)"
Write-Host "##[endgroup]"
$spfxSolutions = Get-Solutions -folderPath $folderPath
$spoAccessToken = Get-AzAccessToken -ResourceUrl "https://${tenantName}.sharepoint.com" -AsSecureString
Write-Host "##[debug]Connecting to SharePoint Online App Catalog"
Assert-SiteAppCatalog -tenantName $tenantName -siteName $siteName -spoAccessToken $spoAccessToken.Token
$spfxSolutions | ForEach-Object {
Upload the package to the site collection app catalog:
# ${$requestUrl}/Add(overwrite=true, url='{filename}')";
$packageInSite = Add-AppInSiteAppCatalog -Overwrite -Publish -requestUrl $requestUrlApps `
-sppkgPath "$folderPath/$_" `
-spoAccessToken $spoAccessToken.Token
Write-Host "Application $($packageInSite.Title) deployed: $($packageInSite.Deployed)"
if ( $null -eq $packageInSite.InstalledVersion ) {
Write-Host "Installing app $($packageInSite.Id) ..."
# ${requestUrl}/AvailableApps/GetById('{app-id}')/Install
Invoke-AppInSiteAppCatalog -action Install -requestUrl $requestUrlApps `
-uniqueId $packageInSite.Id `
-spoAccessToken $spoAccessToken.Token
}
elseif ($packageInSite.CanUpgrade -eq $true) {
Write-Host "Updating installed app $($packageInSite.Id) ..."
# ${requestUrl}/AvailableApps/GetById('{app-id}')/Upgrade
Invoke-AppInSiteAppCatalog -action Upgrade -requestUrl $requestUrlApps `
-uniqueId $packageInSite.Id `
-spoAccessToken $spoAccessToken.Token
}
else {
Write-Host "Version $($packageInSite.AppCatalogVersion) already exists"
}
}
function Get-Solutions {
param(
[string] $folderPath
)
if (Test-Path $folderPath -PathType Container) {
return (Get-Childitem -Path "$folderPath" | Select-Object Name).Name
} else {
Write-Host "No projects detected"
exit 0
}
}
function Assert-SiteAppCatalog {
param(
[string] $tenantName,
[string] $siteName,
[securestring] $spoAccessToken,
[hashtable] $headers = @{
"Accept" = "application/json;odata=nometadata"
"Content-Type" = "application/json;odata=nometadata"
}
)
try {
Write-Host "Ensure site exists"
Invoke-RestMethod -Uri "https://${tenantName}.sharepoint.com/sites/${siteName}/_api/web?`$select=Id,Url" `
-Method Get `
-Headers $headers `
-Authentication Bearer `
-Token $spoAccessToken | Out-Null
} catch {
Write-Host "##[error]Failed to get site: https://${tenantName}.sharepoint.com/sites/${siteName}"
Write-Host "##[error]$($_.Exception.Message)"
exit 1
}
}
function Get-AppMetadata{
param(
[string] $requestUrl,
[string] $uniqueId,
[securestring] $spoAccessToken,
[hashtable] $headers = @{
"Accept" = "application/json;odata=nometadata"
"Content-Type" = "application/json;odata=nometadata"
}
)
# Get app metadata
$appMetadataUri = "${requestUrl}/AvailableApps/GetById('${uniqueId}')"
Write-Host "##[debug]Getting app metadata from $appMetadataUri"
$metadataResponse= Invoke-RestMethod -Uri $appMetadataUri `
-Method GET `
-Headers $headers `
-Authentication Bearer `
-Token $spoAccessToken
@{
Id=$metadataResponse.Id
AppCatalogVersion = $metadataResponse.AppCatalogVersion
CanUpgrade = $metadataResponse.CanUpgrade
Deployed = $metadataResponse.Deployed
InstalledVersion = $metadataResponse.InstalledVersion
IsClientSideSolution = $metadataResponse.IsClientSideSolution
Title = $metadataResponse.Title
}
}
function Invoke-AppInSiteAppCatalog {
param(
[string] $requestUrl,
[string] $uniqueId,
[securestring] $spoAccessToken,
[hashtable] $headers = @{
"Accept" = "application/json;odata=nometadata"
"Content-Type" = "application/json;odata=nometadata"
},
[hashtable] $postObject,
[ValidateSet("Deploy", "Install", "Upgrade")]
[string] $action
)
# Deploy the app
$deployUri = "${requestUrl}/AvailableApps/GetById('${uniqueId}')/$action"
Write-Host "##[debug]Deploying app with ID $uniqueId to $deployUri"
Invoke-RestMethod -Uri $deployUri `
-Method POST `
-Headers $headers `
-Authentication Bearer `
-Token $spoAccessToken `
-Body (ConvertTo-Json -InputObject $postObject -Depth 2) | Out-Null
}
function Add-AppInSiteAppCatalog {
param(
[string] $requestUrl,
[string] $sppkgPath,
[securestring] $spoAccessToken,
[hashtable] $headers = @{
"Accept" = "application/json;odata=nometadata"
"Content-Type" = "application/json;odata=nometadata"
},
[Switch] $Overwrite,
[Switch] $Publish ,
[Switch] $SkipFeatureDeployment
)
# Upload the package to the site collection app catalog:
$sppkg = (Get-Childitem -Path $sppkgPath -Include "*.sppkg" -File -Recurse | Select-Object FullName, Name -first 1)
$_overwrite = if ($Overwrite) { "true" } else { "false" }
$uploadUri = "${requestUrl}/Add(overwrite=${_overwrite},url='$($sppkg.Name)')"
Write-Host "##[debug]Uploading $($sppkg.Name) to $uploadUri"
$uploadResponse = Invoke-RestMethod -Uri $uploadUri `
-Method POST `
-Headers $headers `
-Authentication Bearer `
-Token $spoAccessToken `
-Body (Get-Content -Path $sppkg.FullName -AsByteStream -Raw) `
if ($null -ne $uploadResponse.UniqueId) {
If($Publish){
# Deploy
# ${requestUrl}/AvailableApps/GetById('{app-id}')/Deploy
$props=@{}
If($SkipFeatureDeployment) {
$props.Add("skipFeatureDeployment", "true")
}
Invoke-AppInSiteAppCatalog -action Deploy -requestUrl $requestUrl `
-uniqueId $uploadResponse.UniqueId `
-spoAccessToken $spoAccessToken `
-postObject $props
}
# ${requestUrl}/AvailableApps/GetById('{id}')";
return Get-AppMetadata -requestUrl $requestUrl `
-uniqueId $uploadResponse.UniqueId `
-spoAccessToken $spoAccessToken `
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment