I recently put out a Twitter post about the upcoming capability
that allows you to apply granular permissions to SharePoint Online Sites for Azure AD Applications.
This capability is now live and available in the Microsoft Graph API (both v1.0 and Beta). Find the API reference on Microsoft Graph documentation. I have seen a lot of our customers rely on the legacy 'Access Control Services' (ACS) based apps (apps registered using /_layouts/15/appregnew.aspx page) to meet the site level/granular permissions requirements. With this update you can now stop doing that and embrace the modern way using Microsoft Graph.
In this blog I will show how this is accomplished using PowerShell and Microsoft Graph REST API calls.
The current implementation feels like a MVP('minimal viable product') of a long term solid plan from Microsoft. Therefore, at the moment there is no UI/UX for admins to manage (add/remove permissions at) the individual site level permissions. It's only possible through Microsoft Graph API. You will need two apps to accomplish this.
First is the Azure AD application (let's call it 'client-app') registered that needs to be given app-only permissions to select few sites (as against to all sites in the tenant). First step is to navigate to the "API Permissions" for that app. Select "Application permissions" box. Then select "Sites.Selected" permission scope listed under "Sites" category.
You need another Azure AD application (let's call it 'admin-app') that has 'Sites.FullControl.All' app-only permissions. Assume this is created and managed by your IT Admins. IT admin will use this application (as a helper utility) to call Microsoft Graph and assign 'client-app' the permissions (read or write) for each site that it needs access. In this way the 'client-app' will be able to make queries only to those set of sites. Microsoft graph is updated to support this capability. To add/remove permissions at site collection level, you need to use the /sites/{site-id}/permissions REST API.
Here is an example:
Here is a sample PowerShell script to enumerate existing site level permissions using Microsoft Graph
clear | |
#Provie tenant prefix, Application (client) ID, and client secret of the IT admin app | |
#IT admin app must have sites.fullcontrol app-only perms | |
$tenantPrefix = "Contoso"; | |
$clientId = "Client-ID"; | |
$clientSecret = "Client-Secret"; | |
$tenantName = $tenantPrefix +".onmicrosoft.com"; | |
$tenantDomain = $tenantPrefix +".sharepoint.com"; | |
#Provide site url | |
$sitePath = "https://contoso.sharepoint.com/sites/Web01" | |
$siteName = $sitePath.Split("/")[4] | |
$ReqTokenBody = @{ | |
Grant_Type = "client_credentials" | |
Scope = "https://graph.microsoft.com/.default" | |
client_Id = $clientID | |
Client_Secret = $clientSecret | |
} | |
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantName/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $tenantDomain +':/sites/'+ $siteName +'?$select=id,displayName' | |
try { | |
$spoResult = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Get | |
Write-Host "Site:" $spoResult.displayName | |
} | |
catch { | |
Write-Output "Failed to enumerate the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
Exit | |
} | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $spoResult.id +'/permissions' | |
try { | |
$spoData = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Get -ResponseHeadersVariable spoRespHeaders | |
if ($spoData.value.length -eq 0) | |
{ | |
Write-Host "No site level permissions found" | |
} | |
else { | |
$spoData.value | %{ $_ | ConvertTo-Json -Depth 10 } | |
} | |
} | |
catch { | |
Write-Output "Failed to add permissions the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
} |
NOTE: When we enumerate the granular permissions, its not showing the role (read/write) details. It's showing the unique perm id, display name and client app id. This might be a temporary gap in the functionality.
Here is a script sample to add granular permissions to a site using Microsoft Graph:
clear | |
# Provide tenant prefix, Application (client) ID, and Client secret of the admin app | |
$tenantPrefix = "contoso"; | |
$clientId = "client-id"; | |
$clientSecret = "client-secret"; | |
$tenantName = $tenantPrefix +".onmicrosoft.com"; | |
$tenantDomain = $tenantPrefix +".sharepoint.com"; | |
#Provide the site url | |
$sitePath = "https://contoso.sharepoint.com/sites/Web01" | |
$clientAppId = "client-id-with-sites.selected-perms" | |
$clientAppName = "RestrictedApp" | |
$roles = @("read") #read, write | |
$rolesJson = ConvertTo-Json $roles | |
$siteName = $sitePath.Split("/")[4] | |
$resource = "https://graph.microsoft.com/" | |
$ReqTokenBody = @{ | |
Grant_Type = "client_credentials" | |
Scope = "https://graph.microsoft.com/.default" | |
client_Id = $clientID | |
Client_Secret = $clientSecret | |
} | |
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantName/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $tenantDomain +':/sites/'+ $siteName +'?$select=id,displayName' | |
try { | |
$spoResult = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Get | |
Write-Host "Site: " $spoResult.displayName | |
} | |
catch { | |
Write-Output "Failed to enumerate the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
#Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
Exit | |
} | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $spoResult.id +'/permissions' | |
$postBody = @" | |
{ | |
'roles': $rolesJson, | |
'grantedToIdentities': [{ | |
'application': { | |
'id': '$clientAppId', | |
'displayName': '$clientAppName' | |
} | |
}] | |
} | |
"@ | |
#Here is an example | |
<# | |
$postBody = @' | |
{ | |
'roles': ['write'], | |
'grantedToIdentities': [{ | |
'application': { | |
'id': '3842218f-b211-4444-90f9-ef790e46cf75', | |
'displayName': 'Client App' | |
} | |
}] | |
} | |
'@ | |
#> | |
try { | |
$spoData = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Post -Body $postBody -ContentType "application/json" #-ResponseHeadersVariable spoRespHeaders | |
"Successfully added the granular permissions" | |
$spoData | ConvertTo-Json -Depth 10 | |
} | |
catch { | |
Write-Output "Failed to add permissions the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
#Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
} |
Here is a script to remove all granular permissions or selected granular permissions based on the app id:
clear | |
#Provie tenant prefix, Application (client) ID, and client secret of the IT admin app | |
#IT admin app must have sites.fullcontrol app-only perms | |
$tenantPrefix = "Contoso"; | |
$clientId = "Client-Id"; | |
$clientSecret = "Client-Secret"; | |
$tenantName = $tenantPrefix +".onmicrosoft.com"; | |
$tenantDomain = $tenantPrefix +".sharepoint.com"; | |
#Site url | |
$sitePath = "https://contoso.sharepoint.com/sites/Web01" | |
#Leave this empty to delete all granular perms or provide specific app id | |
$clientAppId = "" #Example: "986f9573-cfcc-4444-b86a-99f9997c3edc" | |
$siteName = $sitePath.Split("/")[4] | |
$resource = "https://graph.microsoft.com/" | |
$ReqTokenBody = @{ | |
Grant_Type = "client_credentials" | |
Scope = "https://graph.microsoft.com/.default" | |
client_Id = $clientID | |
Client_Secret = $clientSecret | |
} | |
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantName/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $tenantDomain +':/sites/'+ $siteName +'?$select=id,displayName' | |
try { | |
$spoResult = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Get | |
Write-Host "Site:" $spoResult.displayName | |
} | |
catch { | |
Write-Output "Failed to enumerate the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
Exit | |
} | |
$baseApiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $spoResult.id +'/permissions/' | |
try { | |
$spoData = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $baseApiUrl -Method Get | |
if ($spoData.value.length -eq 0) | |
{ | |
Write-Host "No site level permissions found" | |
} | |
else { | |
$spoData.value | %{ | |
if (($clientAppId.Trim().Length -ne 0) -and ($clientAppId -eq $_.grantedToIdentities.application.id)) | |
{ | |
#Delete only the requested app perm | |
$apiUrl = $baseApiUrl + $_.id | |
Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Delete | |
Write-Host "Deleted permission id: " $_.id | |
} | |
elseif($clientAppId.Trim().Length -eq 0) | |
{ | |
#Delete all perms | |
$apiUrl = $baseApiUrl + $_.id | |
Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Delete | |
Write-Host "Deleted permission id: " $_.id | |
} | |
} | |
} | |
} | |
catch { | |
Write-Output "Failed to add permissions the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
} |
I created a sample script to finally test this out. This sample script enumerates all the lists in a given SPO site. Below image shows the script failing before giving the granular permissions:
Now I ran the Add-SPOSiteGranularPermission script giving my client-app 'read' permissions to the site. Here is the output from the script:
Below image shows the same script now successful after giving the granular permissions on the site:
Here is the sample script that I used for my sample run:
clear | |
# Application (client) ID, secret, tenant name and site | |
$tenantPrefix = "CONTOSO"; #Pass 'Contoso' for contoso.onmicrosoft.com | |
$clientId = "CLIENT ID"; #Pass the azure ad app id here | |
$clientSecret = "CLIENT SECRET"; #Pass the azure ad app client secret | |
$tenantName = $tenantPrefix +".onmicrosoft.com"; | |
$tenantDomain = $tenantPrefix +".sharepoint.com"; | |
$sitePath = "https://contoso.sharepoint.com/sites/Web01" | |
$siteName = $sitePath.Split("/")[4] | |
$resource = "https://graph.microsoft.com/" | |
$ReqTokenBody = @{ | |
Grant_Type = "client_credentials" | |
Scope = "https://graph.microsoft.com/.default" | |
client_Id = $clientID | |
Client_Secret = $clientSecret | |
} | |
$TokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantName/oauth2/v2.0/token" -Method POST -Body $ReqTokenBody | |
$AccessToken = $TokenResponse.access_token | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $tenantDomain +':/sites/'+ $siteName +'?$select=id,displayName' | |
try { | |
$spoResult = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Get | |
Write-Host "Site: " $spoResult.displayName | |
} | |
catch { | |
Write-Output "Failed to enumerate the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
#Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
Exit | |
} | |
$apiUrl = 'https://graph.microsoft.com/v1.0/sites/'+ $spoResult.id +'/lists?$select=displayName' | |
try { | |
$spoData = Invoke-RestMethod -Headers @{Authorization = "Bearer $($Tokenresponse.access_token)"} -Uri $apiUrl -Method Get -ContentType "text/plain" -ResponseHeadersVariable spoRespHeaders | |
$spoData.Value | FT | |
} | |
catch { | |
Write-Output "Failed to add permissions the site" | |
Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ | |
Write-Host "StatusDescription:" $_.Exception.Response.StatusDescription | |
} |
Some of the limitations I see in the current state:
- Thie granular permissions is limited to application permissions (app-only) for an Azure AD app.
- There is no UI/UX to manage the granular permissions
- There is currently no endpoint to list out sites that has granular permissions enabled for a given AAD app
- Site collection level seems to be the most granular it can go.
Jeremy Kelley (Microsoft PM) presented on this topic in February's Microsoft Graph community call. He answered a bunch of questions and also hinted that this capability is just beginning and it has a long way to go. You can access the recording here.
Finally I am working on a full blown script to manage the site level permissions in bulk. You can retrieve, add, remove permissions by supplying a CSV file as input. I will update the link here once that is ready.
Please leave any comments, questions or feedback.
Top comments (2)
Hi,
Is $roles = @("sp.full control") working for you? I'm getting
"Failed to add permissions the site
StatusCode: 400"
thx for great article!
Sorry for late response. I guess I should register for alerts on comments. I am also getting similar error.
Based on what I gather setting sp.full controll is still not supported. PG will eventually get there. Not sure about the timeline.