Last active
May 30, 2025 13:10
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<# | |
.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 | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[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" | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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