Showing posts with label Powershell. Show all posts
Showing posts with label Powershell. Show all posts

March 3, 2026

Restricting Microsoft Graph Mail Access with Application Access Policies: A Complete Guide

When integrating Microsoft 365 workloads with Microsoft Graph, one of the most overlooked security risks is granting an application too much access. If an Azure AD app is assigned Mail.Read (Application) permissions, it can read email from every mailbox in the tenant using a simple Graph call:

GET https://graph.microsoft.com/v1.0/users/{id}/messages

This default behavior is powerful—but extremely risky.

Most applications only need access to a few mailboxes, not the entire directory. Without restrictions, your app could unintentionally access executive mail, HR communications, or legally sensitive data.

In this blog, we walk through how to restrict your application’s access so it can only read emails from a specific list of users, using Application Access Policies—a secure and recommended approach.

Relevant Microsoft Documentation

Understanding the Problem

Graph Application Permissions grant broad, tenant-wide access. This is ideal for:

  • Background services
  • Daemon applications
  • Automation tools

But it also means the app can access all mailboxes unless restricted.

Examples of apps that should NOT have tenant-wide access:

  • A ticketing system that reads only support@contoso.com
  • An Azure Function processing emails for one department
  • A compliance tool reading from a select shared inbox

To enforce the principle of least privilege, we need mailbox-level controls.

How Application Access Policies Solve This

Application Access Policies allow Exchange Online to limit an application to a specific set of mailboxes, defined by membership in a Mail-Enabled Security Group (MESG).

What the Policy Does

  • Allows Graph access only to users in the MESG
  • Blocks access to all other mailboxes (returns 403 Forbidden)
  • Applies automatically to all Graph calls using application permissions

Important Propagation Delay

After creating or updating a policy, it takes 30 minutes to 2 hours for the policy to fully propagate across the Microsoft 365 ecosystem.

Testing immediately after creation may produce inconsistent results until the policy finishes syncing.

End-to-End Implementation Flow

  1. Create the Azure AD App Registration
  2. Create a Mail-Enabled Security Group (MESG)
  3. Install and Connect Exchange Online PowerShell
  4. Create the Application Access Policy
  5. Validate the Policy
  6. Validate Using Microsoft Graph
  7. Review a Real-World Scenario
  8. Conclusion

1. Create the Azure AD App Registration

Steps

  • Go to Azure AD → App registrations → New registration
  • Enter a name
  • Select "Accounts in this organizational directory"
  • Register the app

Add a Client Secret

Create a client secret for authentication between your application and Microsoft identity platform.

Assign Microsoft Graph Application Permissions

  • Mail.Read
  • Mail.ReadWrite (if required)
  • User.Read.All (optional)

After adding permissions, Grant admin consent.

App registration permissions

2. Create a Mail-Enabled Security Group (MESG)

Only mail-enabled security groups are supported by Application Access Policies.

Unsupported Group Types

  • Microsoft 365 groups
  • Distribution lists
  • Standard security groups

Example MESG:

  • Name: AllowedMailboxAccess
  • Email: allowed.mailaccess@contoso.com

Add Allowed Mailboxes

  • john.doe@contoso.com
  • jane.smith@contoso.com

Do not add users who should be restricted.

MESG configuration

3. PowerShell Requirements

Install Exchange Online Module

Install-Module ExchangeOnlineManagement -Scope CurrentUser

Connect to Exchange Online

Connect-ExchangeOnline

Authentication is required before creating or managing Application Access Policies.

4. Create the Application Access Policy

New-ApplicationAccessPolicy `
  -AppId "YOUR-APP-ID-HERE" `
  -PolicyScopeGroupId "allowed.mailaccess@contoso.com" `
  -AccessRight RestrictAccess `
  -Description "Restrict Graph Mail.Read access to approved users"

What This Enforces

  • The application can only access mailboxes in the MESG
  • All other mailboxes return 403 Forbidden
  • Applies to client_credentials flow

5. Validate the Policy

Test an Allowed Mailbox

Test-ApplicationAccessPolicy `
  -Identity "john.doe@contoso.com" `
  -AppId "YOUR-APP-ID-HERE"

Expected: AccessCheckResult : Granted

Test a Blocked Mailbox

Test-ApplicationAccessPolicy `
  -Identity "unauthorized.user@contoso.com" `
  -AppId "YOUR-APP-ID-HERE"

Expected: AccessCheckResult : Denied

Note:If you receive unexpected results, allow 30 minutes to 2 hours for propagation.

6. Validate Using Microsoft Graph

Allowed mailbox:

GET https://graph.microsoft.com/v1.0/users/john.doe@contoso.com/messages

Should return 200 OK.

Blocked mailbox:

GET https://graph.microsoft.com/v1.0/users/unauthorized.user@contoso.com/messages

Should return 403 Forbidden.

7. Example: Real-World Scenario

An AI email classifier running as an Azure Function should only access:

  • support@contoso.com
  • billing@contoso.com

Without restrictions, it could access executive, HR, or legal mailboxes. With an Application Access Policy, access is limited strictly to approved accounts.

Conclusion

Application Access Policies are the safest and most effective way to restrict Microsoft Graph application permission access to specific mailboxes.

  • Least privilege access
  • Compliance-friendly restrictions
  • No accidental overexposure of user data
  • Server-enforced controls beyond application code

This creates a secure and controlled environment for Exchange Online mailbox access.

How to Audit Specific User Permissions Across All SharePoint Online Sites Using PowerShell

Introduction

Managing user access in SharePoint Online can be challenging – especially when you want to audit permissions for specific users across all site collections.

We use a PowerShell script:

  • Retrieves permissions for multiple users
  • Identifies direct and group-based permissions
  • Scan all accessible SharePoint sites
  • Exports results to an Excel (.xlsx) report

We will guide you step by step on how to configure and run the script.

Overview of the blog in image - Managing SharePoint Online User Permissions with PowerShell

Pre-Requisites:

  1. Admin Credentials:

    You must login using a SharePoint Administrator account.

  2. Azure AD Application Details

    Collect the following values from the Azure AD App Registration:

    • Client ID
    • Tenant ID
  3. Permission to All SharePoint Sites

    Only site you can access will return data.

  4. Users.csv File

    The file contains the list of user email addresses you want to check

    Example Users.csv

    User Email csv file image
  5. Script and CSV File in the Same Folder.

    • GetSpecificUsersPermission.ps1
    • Users.csv

Configure the Script:

  • Open the GetSpecificUsersPermission.ps1 file and update:
  • AdminSiteURL → your admin URL
  • https://yourtenant-admin.sharepoint.com
  • Client ID → your registered application’s Client ID
  • Tenant ID → your Azure AD Tenant ID
  • CSV file name if you changed it.

PowerShell Script: GetSpecificUsersPermission.ps1


# Parameters
$AdminSiteURL = "https://yourtenant-admin.sharepoint.com"
$ClientId = "ClientId"
$TenantId = "TenantId"
$usersCSV = "users.csv"

# Get the folder where this script is located
$scriptFolder = $PSScriptRoot

$ReportOutput = Join-Path -Path $scriptFolder -ChildPath "SpecificUsersPermissionReport.csv"

$UsersCsvPath = Join-Path -Path $scriptFolder -ChildPath $usersCSV

Write-Host "CSV will be saved to: $ReportOutput"
Write-Host "Reading users from: $UsersCsvPath"

$UsersToCheck = Import-Csv -Path $UsersCsvPath | Select-Object -ExpandProperty UserEmail

# Connect to Admin Center
$AdminConnection = Connect-PnPOnline -Url $AdminSiteURL -ClientId $ClientId `
-Tenant $TenantId -Interactive -ReturnConnection

# Get all site collections
$Sites = Get-PnPTenantSite -Connection $AdminConnection

$Results = @()

foreach ($Site in $Sites) {
	Write-Host "Processing site: $($Site.Url)" -ForegroundColor Cyan

	# Connect to the actual site
	$SiteConnection = Connect-PnPOnline -Url $Site.Url -ClientId $ClientId `
	-Tenant $TenantId

	# Get the root web and role assignments
	$Web = Get-PnPWeb -Connection $SiteConnection -Includes `
	RoleAssignments, HasUniqueRoleAssignments

	Get-PnPProperty -ClientObject $Web -Property RoleAssignments

	foreach ($RoleAssignment in $Web.RoleAssignments) {

		Get-PnPProperty -ClientObject $RoleAssignment -Property `
		RoleDefinitionBindings, Member

		# Direct user
		if ($RoleAssignment.Member.PrincipalType -eq "User") {

			$UserEmail = ($RoleAssignment.Member.LoginName -split '\|')[-1]

			if ($UsersToCheck -contains $UserEmail) {

				$Results += [PSCustomObject]@{
					SiteURL           = $Site.Url
					UserOrGroupName   = $RoleAssignment.Member.Title
					Type              = "Direct User"
					PermissionLevels  = ($RoleAssignment.RoleDefinitionBindings |
										  Select -ExpandProperty Name) -join ", "
				}
			}
		}

		# SharePoint Group
		elseif ($RoleAssignment.Member.PrincipalType -eq "SharePointGroup") {

			try {
				$Group = Get-PnPGroup -Identity $RoleAssignment.Member.Title `
				-Includes Users -Connection $SiteConnection

				foreach ($User in $Group.Users) {

					$UserEmail = ($User.LoginName -split '\|')[-1]

					if ($UsersToCheck -contains $UserEmail) {

						$Results += [PSCustomObject]@{
							SiteURL           = $Site.Url
							UserOrGroupName   = $User.Title
							Type              = "User (via Group: $($RoleAssignment.Member.Title))"
							PermissionLevels  = ($RoleAssignment.RoleDefinitionBindings |
												  Select -ExpandProperty Name) -join ", "
						}
					}
				}
			}
			catch {
				Write-Warning "Cannot access group $($RoleAssignment.Member.Title) in site $($Site.Url). Skipping."
			}
			finally {
				if ($SiteConnection) {
					Disconnect-PnPOnline -Connection $SiteConnection
				}
			}
		}
	}
}

if ($AdminConnection) {
	Disconnect-PnPOnline
}

# Export results to CSV
$Results | Export-Csv -Path $ReportOutput -NoTypeInformation

Write-Host "Report generated successfully at $ReportOutput"
	

How to Run the Script:

  1. Open PowerShell
  2. Navigate to your script folder:

    Cd “C:\YourFolder”

  3. Run the script:

    .\GetSpecificUsersPermission.ps1

  4. Enter your admin credentials
  5. Complete browser authentication
  6. Wait for the report to be generated

Output Report:

The script generates an Excel report containing:

  • Site URL
  • User Email
  • Direct Permissions
  • Group memberships
  • Role definitions
  • Permission levels

You will find the .xlsx report in the same folder as the script

Conclusion:

This approach gives SharePoint administrators a quick and efficient way to:

  • Audit Permissions.
  • Verify access.
  • View direct & group permissions.
  • Export clean reports for governance.
  • Just update the script, run it and your Excel report is ready.

If you have any questions you can reach out our SharePoint Consulting team here .

May 29, 2025

Streamline SPFx Builds with Azure DevOps CI/CD Pipeline – Part 2: Automating Deployment (CD)

Introduction

In the First Part, we built a Continuous Integration (CI) pipeline to automatically package our SharePoint Framework (SPFx) solution. Now it’s time to automate deployment across multiple SharePoint sites using Continuous Deployment (CD).

This blog covers setting up a CD pipeline in Azure DevOps, triggered automatically after your CI pipeline finishes, to deploy your .sppkg file to all the required sites. 


Step 1: Prepare Your PowerShell Deployment Script

We’ll use PowerShell with PnP.PowerShell to handle the deployment. This script connects to SharePoint Online, uploads the .sppkg package, and publishes it on each target site.

Here's the script:

  Param (  
   [string] $RootPath = $(Throw "Root Path is required."),  
   [string] $ClientId = $(Throw "ClientId is required."),  
   [string] $TenantId = $(Throw "TenantId is required."),  
   [string] $TenantURL = $(Throw "Tenant URL is required."),  
   [string] $ClientSecret = $(Throw "Client Secret is required."),  
   [string] $packagename = $(Throw "Package name is required.")  
 )  
 $SiteURL = "$($TenantURL)/sites/TestSite"  
 $ModernPOPAppPath = "$RootPath\drop\$packagename"  
 $currentPOPSite = ""  
 function ReconnectPNPConnection {  
   Write-Host "Reconnecting to site $($currentPOPSite)"  
   Disconnect-PnPOnline  
   Connect-PnPOnline -Url $currentPOPSite -ClientId $ClientId -ClientSecret $ClientSecret -Tenant $TenantId  
 }  
 function addCustomSPFxApps {  
   $appCatalog = Get-PnPSiteCollectionAppCatalog -CurrentSite -ErrorAction SilentlyContinue  
   while ($null -eq $appCatalog) {  
     Write-Host "Waiting for app catalog creation..."  
     Start-Sleep -Seconds 30  
     $appCatalog = Get-PnPSiteCollectionAppCatalog -CurrentSite -ErrorAction SilentlyContinue  
   }  
   $uploadApp = $false  
   while ($uploadApp -eq $false) {  
     try {  
       Write-Host "Uploading SPFx app..."  
       $App = Add-PnPApp -Path $ModernPOPAppPath -Scope Site -Overwrite -Timeout 900  
       Write-Host "Publishing SPFx app..."  
       Publish-PnPApp -Identity $App.ID -Scope Site -SkipFeatureDeployment -ErrorAction SilentlyContinue  
       Write-Host "SPFx app deployed successfully."  
       Start-Sleep -Seconds 60  
       $uploadApp = $true  
     } catch {  
       Write-Host $_.Exception.Message  
       Write-Host "Retrying after 30 seconds..."  
       Start-Sleep -Seconds 30  
       ReconnectPNPConnection  
     }  
   }  
 }  
 Write-Host -f Cyan "Installing PnP.PowerShell module..."  
 [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12  
 Install-Module PnP.PowerShell -RequiredVersion 2.4.0 -Force -Scope CurrentUser  
 Import-Module -Name "PnP.PowerShell"  
 Write-Host "Connecting to $($SiteURL)..."  
 Connect-PnPOnline -Url $SiteURL -ClientId $ClientId -ClientSecret $ClientSecret -Tenant $TenantId  
 $currentPOPSite = $SiteURL  
 addCustomSPFxApps  
 Write-Host "Disconnecting from $($SiteURL)..."  
 Disconnect-PnPOnline  

We need to upload this PowerShell script to a document folder as shown below and add a step in the CI (Continuous Integration) pipeline to copy this file into the artifacts.


Step 2: Set Up the CD Pipeline in Azure DevOps

1. Create the Release Pipeline:

  • Navigate to the Release section in Azure DevOps.
  • Click New Pipeline.

2. Add Artifacts:

  • Link your CI pipeline’s artifacts (the .sppkg file and related files).test

3. Enable Continuous Deployment Trigger:

  • Click the lightning bolt icon next to the artifact and enable the trigger to deploy automatically after CI.

4. Add a Stage:

  • Click Add a stage and choose Empty Job.

5. Add PowerShell Task:

  • In the stage, click to add a task.
  • Choose PowerShell.
  • Set the script path to point to your deployment script (e.g., Documents/CDScript.ps1).
  • Set the necessary parameters (RootPath, ClientId, TenantId, etc.).

6. Save and Deploy:

  • Save the pipeline and test it by running the full CI/CD process.

Conclusion

By integrating both CI and CD pipelines in Azure DevOps, you’ve established a fully automated and reliable deployment process for your SPFx solutions:

  • CI Pipeline: Automatically builds and packages your SPFx solution on every code push.
  • CD Pipeline: Seamlessly deploys the package to all required SharePoint sites immediately after a successful build.

This setup not only saves time but also ensures consistency and reduces the risk of manual deployment errors across environments.

If you have any questions you can reach out our SharePoint Consulting team here.

April 17, 2025

Custom PowerShell Script to Copy Site Groups, Permissions, and Settings Without Migration Tool

Introduction 

Migrating permissions, site groups, and settings from a SharePoint Hub Site to an associated site is a common task, especially in large organizations. While available migration tools can make this easy, they often come at a cost. 

In this blog post, we’ll walk through a custom PowerShell script that automates this process without using any migration tool.  

What This Script Does 

  • Connects to both Hub Site and Associated Site 
  • Copies custom permission levels 
  • Copies site groups with users 
  • Applies appropriate role assignments 
  • Updates sharing capabilities 
  • Streamlines migration process for SharePoint Online environments 

Script Parameters 

Param ( 
    [string] $ClientId = $(Throw "Please provide ClientId"), 
    [string] $HubSiteURL = $(Throw "Please provide HubSiteURL"), 
    [string] $AdminSiteURL = $(Throw "Please provide AdminSiteURL"), 
    [string] $ClientSecret = $(Throw "Please provide ClientSecret"), 
    [string] $AssociatedSiteURL = $(Throw "Please provide Associated Site URL") 
) 

You’ll need to register an Azure AD App with appropriate SharePoint API permissions and provide its ClientId and ClientSecret. This ensures secure authentication without using stored credentials. 

Authentication and Setup 

$HubSiteConnection = Connect-PnPOnline -Url $HubSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection 
$AssociatedSiteConnection = Connect-PnPOnline -Url $AssociatedSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection

This part of the script connects to both the Hub Site and the Associated Site using PnP PowerShell, returning secure connections for use in the following functions. 

Updating Sharing Capability 

function Update-ExternalSharing { 
    param ( 
        [string] $AssociatedSiteURL, 
        [string] $AdminSiteURL 
    ) 
 
    Connect-PnPOnline -Url $AdminSiteURL -ClientId $ClientId -ClientSecret $ClientSecret 
    Set-PnPTenantSite -Url $AssociatedSiteURL -SharingCapability ExternalUserSharingOnly 
}
This function sets the sharing capability of the associated site to allow external users to access the site — but only if they are authenticated. This is done by updating the SharingCapability property of the site to ExternalUserSharingOnly. 

Copying Permission Levels 

function Copy-PermissionLevels { 
    param ( 
        $HubSiteConnection, 
        $AssociatedSiteConnection 
    ) 
 
    $AllPermissionLevels = Get-PnPRoleDefinition -Connection $HubSiteConnection 
 
    foreach ($PermissionLevel in $AllPermissionLevels) { 
        if (-not $PermissionLevel.Hidden) { 
            $ExistingPermission = Get-PnPRoleDefinition -Identity $PermissionLevel.Name -Connection $AssociatedSiteConnection -ErrorAction SilentlyContinue 
            if (!$ExistingPermission) { 
                $selectedPermissions = New-Object Microsoft.SharePoint.Client.BasePermissions 
                [Enum]::GetValues([Microsoft.SharePoint.Client.PermissionKind]) | ForEach-Object { 
                    if ($PermissionLevel.BasePermissions.Has($_)) { 
                        $selectedPermissions.Set($_) 
                    } 
                } 
 
                $newRole = Add-PnPRoleDefinition -RoleName $PermissionLevel.Name -Description $PermissionLevel.Description -Connection $AssociatedSiteConnection 
                $newRole.BasePermissions = $selectedPermissions 
                $newRole.Update() 
                Invoke-PnPQuery -Connection $AssociatedSiteConnection 
            } 
        } 
    } 
} 
This function copies non-hidden permission levels from the hub site to the associated site. It checks if the permission already exists to avoid duplication and applies the same Base Permissions.  Copying Groups and Users 
function Copy-HubSiteGroups { 
    param ( 
        $HubSiteName, 
        $AssociatedSiteName, 
        $HubSiteConnection, 
        $AssociatedSiteConnection 
    ) 
 
    $GroupMappings = @( 
        @{ Source = "$HubSiteName Owners"; Destination = "$AssociatedSiteName Owners" }, 
        @{ Source = "$HubSiteName Members"; Destination = "$AssociatedSiteName Members" }, 
        @{ Source = "$HubSiteName Visitors"; Destination = "$AssociatedSiteName Visitors" } 
    ) 
 
    foreach ($Mapping in $GroupMappings) { 
        $SourceGroup = Get-PnPGroup -Identity $Mapping.Source -Connection $HubSiteConnection 
        $DestinationGroup = Get-PnPGroup -Identity $Mapping.Destination -Connection $AssociatedSiteConnection -ErrorAction SilentlyContinue 
 
        if (-not $DestinationGroup) { 
            New-PnPGroup -Title $Mapping.Destination -Connection $AssociatedSiteConnection 
        } 
 
        $SourceGroup.Users | ForEach-Object { 
            $LoginName = if ($_ -like "*#ext#*") { $_.Email } else { $_.LoginName } 
            Add-PnPGroupMember -Group $Mapping.Destination -LoginName $LoginName -Connection $AssociatedSiteConnection 
        } 
    } 
} 

This function replicates SharePoint default groups (Owners, Members, Visitors) from the hub to the associated site. It handles both internal and external users and ensures group membership is preserved. 

Main Function

To simplify script execution, we can wrap all functional calls inside a Main function:

function Main {
    # Connect to the Hub Site and Associated Site using PnP PowerShell
    $HubSiteConnection = Connect-PnPOnline -Url $HubSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection
    $AssociatedSiteConnection = Connect-PnPOnline -Url $AssociatedSiteURL -ClientId $ClientId -ClientSecret $ClientSecret -ReturnConnection

    # Update external sharing settings before proceeding
    Update-ExternalSharing -AdminSiteURL $AdminSiteURL -AssociatedSiteURL $AssociatedSiteURL

    # Copy custom permission levels from Hub Site to Associated Site
    Copy-PermissionLevels -HubSiteConnection $HubSiteConnection -AssociatedSiteConnection $AssociatedSiteConnection

    # Extract names of sites to match default group names
    $HubSiteName = ($HubSiteURL -split "/")[-1]
    $AssociatedSiteName = ($AssociatedSiteURL -split "/")[-1]

    # Copy site groups and their users
    Copy-HubSiteGroups -HubSiteName $HubSiteName -AssociatedSiteName $AssociatedSiteName -HubSiteConnection $HubSiteConnection -AssociatedSiteConnection $AssociatedSiteConnection
}

Main
Running the Script To run this PowerShell script, first make sure you’ve saved it with a .ps1 extension — for example, name it CopyGroupsAndPermissions.ps1. 

Once saved, navigate to the directory where the script is stored using PowerShell and run the following command (make sure to replace the placeholder values with your actual credentials and URLs): 

./CopyGroupsAndPermissions.ps1 ` 
    -ClientId "xxxx-xxxx-xxxx-xxxx" ` 
    -HubSiteURL "https://yourtenant.sharepoint.com/sites/hubsite" ` 
    -ClientSecret "your-client-secret" ` 
    -AdminSiteURL "https://yourtenant-admin.sharepoint.com" ` 
    -AssociatedSiteURL "https://yourtenant.sharepoint.com/sites/associatedsite" 
  

Final Thoughts 

This script is ideal for scenarios where: 

  • You need to replicate security and structure across multiple sites 
  • You want to avoid third-party tools like ShareGate 

If you're working on managing large SharePoint environments with many associated sites, this can save time and reduce manual effort.

If you have any questions you can reach out our SharePoint Consulting team here.