Move SharePoint permissions script to m365/sharepoint and update docs
This commit is contained in:
parent
76f808acd5
commit
5514553dd3
@ -11,6 +11,8 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management
|
||||
- `get-mailbox-with-permissions.ps1`
|
||||
- `scripts/m365/identity/`
|
||||
- `m365-users-mfa.ps1`
|
||||
- `scripts/m365/sharepoint/`
|
||||
- `get-sharepoint-permissions.ps1`
|
||||
- `scripts/windows/autopilot/`
|
||||
- `Get-WindowsAutoPilotInfo.ps1`
|
||||
- `scripts/windows/endpoint/`
|
||||
@ -32,7 +34,7 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management
|
||||
|
||||
## Important Note
|
||||
|
||||
Existing script filenames are intentionally **unchanged** to preserve compatibility with existing guides and runbooks.
|
||||
Legacy script filenames are intentionally **unchanged** to preserve compatibility with existing guides and runbooks. New scripts may use consistent lowercase naming.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ This overview helps update guides after the repository restructuring.
|
||||
|
||||
## Important
|
||||
|
||||
- Script filenames were not changed.
|
||||
- Legacy script filenames were not changed.
|
||||
- Only folder locations were updated.
|
||||
|
||||
## Script Locations
|
||||
@ -21,6 +21,8 @@ This overview helps update guides after the repository restructuring.
|
||||
- New path: `scripts/m365/exchange/get-mailbox-with-permissions.ps1`
|
||||
- `m365-users-mfa.ps1`
|
||||
- New path: `scripts/m365/identity/m365-users-mfa.ps1`
|
||||
- `Get-SharePointPermissions.ps1`
|
||||
- New path: `scripts/m365/sharepoint/get-sharepoint-permissions.ps1`
|
||||
- `schedule_reboot.ps1`
|
||||
- New path: `scripts/windows/endpoint/schedule_reboot.ps1`
|
||||
- `uninstall-MS-Visual-C++-2010.ps1`
|
||||
|
||||
549
scripts/m365/sharepoint/get-sharepoint-permissions.ps1
Normal file
549
scripts/m365/sharepoint/get-sharepoint-permissions.ps1
Normal file
@ -0,0 +1,549 @@
|
||||
#Requires -Modules Microsoft.Graph.Authentication, ImportExcel
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Audits unique SharePoint permissions at library, folder, and file level across all sites.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads site URLs from a CSV export (SharePoint Admin Center) and retrieves all
|
||||
permissions via the Microsoft Graph API. Results are streamed directly to CSV
|
||||
after each site to minimize memory usage. An Excel report is generated at the end.
|
||||
|
||||
=========================================================================
|
||||
REQUIRED AZURE APP REGISTRATION PERMISSIONS
|
||||
=========================================================================
|
||||
Create an App Registration in Azure Portal (Entra ID) with the following
|
||||
APPLICATION permissions (not Delegated) and grant Admin Consent for all:
|
||||
|
||||
Microsoft Graph:
|
||||
- Sites.Read.All : Read all SharePoint site collections and their contents
|
||||
- Files.Read.All : Read all files in all site document libraries
|
||||
- User.Read.All : Resolve user IDs to display names / UPNs
|
||||
- Group.Read.All : Resolve group IDs to display names
|
||||
|
||||
How to set up:
|
||||
1. Go to Azure Portal > Microsoft Entra ID > App Registrations > New Registration
|
||||
2. Give it a name (e.g. "SharePoint Permissions Audit")
|
||||
3. Go to API Permissions > Add a Permission > Microsoft Graph > Application Permissions
|
||||
4. Add: Sites.Read.All, Files.Read.All, User.Read.All, Group.Read.All
|
||||
5. Click "Grant Admin Consent for <tenant>"
|
||||
6. Go to Certificates & Secrets > New Client Secret
|
||||
7. Copy the SECRET VALUE (not the Secret ID) immediately after creation
|
||||
8. Note down the Application (client) ID and the Directory (tenant) ID
|
||||
=========================================================================
|
||||
|
||||
.PARAMETER TenantId
|
||||
Azure AD / Entra ID Tenant ID (found on the App Registration overview page).
|
||||
|
||||
.PARAMETER ClientId
|
||||
Application (Client) ID of the App Registration.
|
||||
|
||||
.PARAMETER ClientSecret
|
||||
Client Secret VALUE (not the Secret ID) of the App Registration.
|
||||
|
||||
.PARAMETER SitesCsvPath
|
||||
Path to the CSV file exported from the SharePoint Admin Center.
|
||||
The CSV must contain a column named 'URL' with the site URLs.
|
||||
Export: SharePoint Admin Center > Active Sites > Export to CSV
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Folder where output files (CSV, Excel, log) will be saved. Defaults to current directory.
|
||||
|
||||
.PARAMETER ExcludedSites
|
||||
Optional array of site URLs to skip during the scan.
|
||||
|
||||
.PARAMETER IncludeFileLevel
|
||||
Switch to enable file-level permission scanning in addition to libraries and folders.
|
||||
Warning: this significantly increases scan time on large tenants.
|
||||
|
||||
.EXAMPLE
|
||||
# Scan all sites using the exported CSV
|
||||
.\Get-SharePointPermissions.ps1 `
|
||||
-TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
|
||||
-ClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
|
||||
-ClientSecret "your-secret-value-here" `
|
||||
-SitesCsvPath "C:\scripts\Sites.csv" `
|
||||
-OutputPath "C:\scripts\Reports"
|
||||
|
||||
.EXAMPLE
|
||||
# Include file-level permissions and skip a specific site
|
||||
.\Get-SharePointPermissions.ps1 `
|
||||
-TenantId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
|
||||
-ClientId "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" `
|
||||
-ClientSecret "your-secret-value-here" `
|
||||
-SitesCsvPath "C:\scripts\Sites.csv" `
|
||||
-OutputPath "C:\scripts\Reports" `
|
||||
-IncludeFileLevel `
|
||||
-ExcludedSites @("https://contoso.sharepoint.com/sites/HR")
|
||||
|
||||
.NOTES
|
||||
Monitor progress: Get-Content "C:\scripts\Reports\SharePoint_Permissions_*.csv" -Wait -Tail 20
|
||||
Do NOT open the CSV in Excel while the script is running - this locks the file.
|
||||
Use Notepad or Notepad++ to view the CSV during the scan.
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$TenantId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ClientId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$ClientSecret,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$SitesCsvPath,
|
||||
|
||||
[string]$OutputPath = (Get-Location).Path,
|
||||
|
||||
[string[]]$ExcludedSites = @(),
|
||||
|
||||
[switch]$IncludeFileLevel
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
#region ── Module check ──────────────────────────────────────────────────────
|
||||
# Ensure required PowerShell modules are installed and loaded.
|
||||
# Microsoft.Graph.Authentication : Connect to Graph API and make REST calls
|
||||
# ImportExcel : Generate formatted Excel reports without Excel installed
|
||||
|
||||
foreach ($mod in @('Microsoft.Graph.Authentication', 'ImportExcel')) {
|
||||
if (-not (Get-Module -ListAvailable -Name $mod)) {
|
||||
Write-Host "[SETUP] Installing module '$mod'..." -ForegroundColor Yellow
|
||||
Install-Module $mod -Scope CurrentUser -Force -AllowClobber
|
||||
}
|
||||
Import-Module $mod -ErrorAction Stop
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Output file paths ─────────────────────────────────────────────────
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$csvOut = Join-Path $OutputPath "SharePoint_Permissions_$timestamp.csv"
|
||||
$xlsxOut = Join-Path $OutputPath "SharePoint_Permissions_$timestamp.xlsx"
|
||||
$logOut = Join-Path $OutputPath "SharePoint_Permissions_$timestamp.log"
|
||||
|
||||
if (-not (Test-Path $OutputPath)) {
|
||||
New-Item -ItemType Directory -Path $OutputPath | Out-Null
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Logging ───────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$entry = "[{0}] [{1}] {2}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
|
||||
Add-Content -Path $logOut -Value $entry
|
||||
switch ($Level) {
|
||||
'ERROR' { Write-Host $entry -ForegroundColor Red }
|
||||
'WARNING' { Write-Host $entry -ForegroundColor Yellow }
|
||||
'SUCCESS' { Write-Host $entry -ForegroundColor Green }
|
||||
default { Write-Host $entry }
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Authentication ────────────────────────────────────────────────────
|
||||
# Uses the OAuth2 client credentials flow directly via Invoke-RestMethod.
|
||||
# This avoids MSAL.PS compatibility issues with PowerShell strict mode.
|
||||
|
||||
Write-Log "Script started."
|
||||
|
||||
try {
|
||||
$tokenBody = @{
|
||||
grant_type = 'client_credentials'
|
||||
client_id = $ClientId
|
||||
client_secret = $ClientSecret
|
||||
scope = 'https://graph.microsoft.com/.default'
|
||||
}
|
||||
$tokenResponse = Invoke-RestMethod `
|
||||
-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
|
||||
-Method POST -Body $tokenBody -ContentType 'application/x-www-form-urlencoded'
|
||||
|
||||
Connect-MgGraph `
|
||||
-AccessToken ($tokenResponse.access_token | ConvertTo-SecureString -AsPlainText -Force) `
|
||||
-NoWelcome
|
||||
|
||||
Write-Log "Authentication successful." -Level 'SUCCESS'
|
||||
} catch {
|
||||
Write-Log "Authentication failed: $_" -Level 'ERROR'
|
||||
exit 1
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Graph REST helper with automatic pagination ───────────────────────
|
||||
# Graph API returns results in pages (default 200 items per page).
|
||||
# This function follows @odata.nextLink until all results are retrieved.
|
||||
|
||||
function Invoke-GraphGet {
|
||||
param([string]$Uri)
|
||||
$results = [System.Collections.Generic.List[object]]::new()
|
||||
$nextUri = $Uri
|
||||
while ($nextUri) {
|
||||
$resp = Invoke-MgGraphRequest -Uri $nextUri -Method GET -OutputType PSObject
|
||||
$nextUri = $null
|
||||
if ($resp.PSObject.Properties.Name -contains '@odata.nextLink') {
|
||||
$nextUri = $resp.'@odata.nextLink'
|
||||
}
|
||||
if ($resp.PSObject.Properties.Name -contains 'value') {
|
||||
foreach ($item in $resp.value) { $results.Add($item) }
|
||||
} elseif ($null -ne $resp) {
|
||||
$results.Add($resp)
|
||||
}
|
||||
}
|
||||
return $results
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Recursive drive item retrieval ────────────────────────────────────
|
||||
# Recursively fetches all folders (and optionally files) within a drive.
|
||||
# Recurses into subfolders to build a complete tree.
|
||||
|
||||
function Get-AllDriveItems {
|
||||
param([string]$DriveId, [string]$ItemId = 'root')
|
||||
$all = [System.Collections.Generic.List[object]]::new()
|
||||
try {
|
||||
$children = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/children"
|
||||
foreach ($child in $children) {
|
||||
$all.Add($child)
|
||||
if ($child.PSObject.Properties.Name -contains 'folder') {
|
||||
$sub = Get-AllDriveItems -DriveId $DriveId -ItemId $child.id
|
||||
foreach ($s in $sub) { $all.Add($s) }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Log " Error retrieving children of '$ItemId': $_" -Level 'WARNING'
|
||||
}
|
||||
return $all
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Principal name lookup with caching ────────────────────────────────
|
||||
# Resolves Azure AD user/group IDs to readable display names.
|
||||
# Results are cached to avoid duplicate API calls for the same principal.
|
||||
|
||||
$principalCache = @{}
|
||||
|
||||
function Get-PrincipalName {
|
||||
param([string]$Id, [string]$Type)
|
||||
if (-not $Id) { return 'Unknown' }
|
||||
$key = "$Type|$Id"
|
||||
if ($principalCache.ContainsKey($key)) { return $principalCache[$key] }
|
||||
$name = "$Type`: $Id"
|
||||
try {
|
||||
if ($Type -eq 'user') {
|
||||
$u = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/users/${Id}?`$select=displayName,userPrincipalName" -Method GET -OutputType PSObject
|
||||
$name = "$($u.displayName) ($($u.userPrincipalName))"
|
||||
} elseif ($Type -eq 'group') {
|
||||
$g = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/${Id}?`$select=displayName" -Method GET -OutputType PSObject
|
||||
$name = $g.displayName
|
||||
}
|
||||
} catch { }
|
||||
$principalCache[$key] = $name
|
||||
return $name
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Permission record builder ─────────────────────────────────────────
|
||||
# Retrieves permissions for a single drive item and returns structured records.
|
||||
# ForceInclude: always return records even if only inherited permissions exist (used for library root).
|
||||
# Without ForceInclude: only returns records if explicit (non-inherited) grants are found.
|
||||
|
||||
function Get-ItemPermissionRecords {
|
||||
param(
|
||||
[string]$DriveId,
|
||||
[string]$ItemId,
|
||||
[string]$ItemName,
|
||||
[string]$ItemPath,
|
||||
[string]$ItemType, # 'Library', 'Folder', or 'File'
|
||||
[string]$SiteName,
|
||||
[string]$SiteUrl,
|
||||
[string]$LibraryName,
|
||||
[switch]$ForceInclude
|
||||
)
|
||||
|
||||
$records = [System.Collections.Generic.List[object]]::new()
|
||||
|
||||
try {
|
||||
$perms = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/permissions"
|
||||
} catch {
|
||||
Write-Log " Error retrieving permissions for '$ItemPath': $_" -Level 'WARNING'
|
||||
return $records
|
||||
}
|
||||
|
||||
if (-not $perms -or $perms.Count -eq 0) { return $records }
|
||||
|
||||
# Only include items with explicit direct grants (not just sharing links), unless ForceInclude
|
||||
$hasExplicitGrants = $perms | Where-Object {
|
||||
($_.PSObject.Properties.Name -contains 'grantedToV2' -and $_.grantedToV2) -or
|
||||
($_.PSObject.Properties.Name -contains 'grantedTo' -and $_.grantedTo)
|
||||
}
|
||||
if (-not $ForceInclude -and -not $hasExplicitGrants) { return $records }
|
||||
|
||||
foreach ($perm in $perms) {
|
||||
$roles = if ($perm.PSObject.Properties.Name -contains 'roles' -and $perm.roles) { $perm.roles -join ', ' } else { 'read' }
|
||||
$grantedTo = 'Unknown'
|
||||
$linkType = ''
|
||||
|
||||
# Resolve who the permission is granted to (Graph API v2 properties take priority)
|
||||
if ($perm.PSObject.Properties.Name -contains 'grantedToV2' -and $perm.grantedToV2) {
|
||||
$g = $perm.grantedToV2
|
||||
if ($g.PSObject.Properties.Name -contains 'user' -and $g.user) { $grantedTo = Get-PrincipalName -Id $g.user.id -Type 'user' }
|
||||
elseif ($g.PSObject.Properties.Name -contains 'group' -and $g.group) { $grantedTo = Get-PrincipalName -Id $g.group.id -Type 'group' }
|
||||
elseif ($g.PSObject.Properties.Name -contains 'siteUser' -and $g.siteUser) { $grantedTo = $g.siteUser.displayName }
|
||||
elseif ($g.PSObject.Properties.Name -contains 'siteGroup' -and $g.siteGroup) { $grantedTo = $g.siteGroup.displayName }
|
||||
} elseif ($perm.PSObject.Properties.Name -contains 'grantedToIdentitiesV2' -and $perm.grantedToIdentitiesV2) {
|
||||
# Multiple recipients (e.g. sharing link used by multiple people)
|
||||
$names = foreach ($identity in $perm.grantedToIdentitiesV2) {
|
||||
if ($identity.PSObject.Properties.Name -contains 'user' -and $identity.user) { Get-PrincipalName -Id $identity.user.id -Type 'user' }
|
||||
elseif ($identity.PSObject.Properties.Name -contains 'group' -and $identity.group) { Get-PrincipalName -Id $identity.group.id -Type 'group' }
|
||||
elseif ($identity.PSObject.Properties.Name -contains 'siteUser' -and $identity.siteUser) { $identity.siteUser.displayName }
|
||||
else { 'Unknown' }
|
||||
}
|
||||
$grantedTo = ($names | Where-Object { $_ }) -join '; '
|
||||
} elseif ($perm.PSObject.Properties.Name -contains 'grantedTo' -and $perm.grantedTo) {
|
||||
# Fallback: older grantedTo property
|
||||
$g = $perm.grantedTo
|
||||
if ($g.PSObject.Properties.Name -contains 'user' -and $g.user) { $grantedTo = "$($g.user.displayName) ($($g.user.email))" }
|
||||
elseif ($g.PSObject.Properties.Name -contains 'group' -and $g.group) { $grantedTo = $g.group.displayName }
|
||||
}
|
||||
|
||||
# Detect sharing links (anonymous, organization-wide, or specific people links)
|
||||
if ($perm.PSObject.Properties.Name -contains 'link' -and $perm.link) {
|
||||
$linkType = "SharingLink ($($perm.link.type), $($perm.link.scope))"
|
||||
if ($grantedTo -eq 'Unknown') { $grantedTo = $linkType }
|
||||
}
|
||||
|
||||
$records.Add([PSCustomObject]@{
|
||||
SiteName = $SiteName
|
||||
SiteUrl = $SiteUrl
|
||||
Library = $LibraryName
|
||||
ItemType = $ItemType
|
||||
ItemName = $ItemName
|
||||
ItemPath = $ItemPath
|
||||
GrantedTo = $grantedTo
|
||||
Permissions = $roles
|
||||
LinkType = $linkType
|
||||
ScannedAt = (Get-Date -Format 'yyyy-MM-dd HH:mm')
|
||||
})
|
||||
}
|
||||
|
||||
return $records
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Load site list from CSV ───────────────────────────────────────────
|
||||
# Reads site URLs from the SharePoint Admin Center export CSV.
|
||||
# Export location: SharePoint Admin Center > Active Sites > Export
|
||||
|
||||
Write-Log "Loading sites from CSV: $SitesCsvPath"
|
||||
|
||||
if (-not (Test-Path $SitesCsvPath)) {
|
||||
Write-Log "CSV file not found: $SitesCsvPath" -Level 'ERROR'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$csvSites = Import-Csv -Path $SitesCsvPath
|
||||
$urlColumn = $csvSites[0].PSObject.Properties.Name | Where-Object { $_ -match '^url$' } | Select-Object -First 1
|
||||
|
||||
if (-not $urlColumn) {
|
||||
Write-Log "No 'URL' column found in CSV. Available columns: $($csvSites[0].PSObject.Properties.Name -join ', ')" -Level 'ERROR'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$siteUrls = $csvSites.$urlColumn | Where-Object {
|
||||
$_ -and $_ -notmatch '/personal/' -and $_ -notin $ExcludedSites
|
||||
}
|
||||
|
||||
Write-Log "Sites loaded from CSV: $($siteUrls.Count)" -Level 'SUCCESS'
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Main scan loop ────────────────────────────────────────────────────
|
||||
# Streams permission records directly to CSV after each site to keep memory usage low.
|
||||
# The CSV file is written incrementally - do NOT open it in Excel during the scan.
|
||||
|
||||
$csvHeaders = 'SiteName;SiteUrl;Library;ItemType;ItemName;ItemPath;GrantedTo;Permissions;LinkType;ScannedAt'
|
||||
Set-Content -Path $csvOut -Value $csvHeaders -Encoding UTF8
|
||||
$recordCount = 0
|
||||
|
||||
function Write-RecordToCsv {
|
||||
# Appends one or more permission records to the CSV file immediately.
|
||||
param([object[]]$Records)
|
||||
foreach ($rec in $Records) {
|
||||
$line = '"{0}";"{1}";"{2}";"{3}";"{4}";"{5}";"{6}";"{7}";"{8}";"{9}"' -f `
|
||||
$rec.SiteName, $rec.SiteUrl, $rec.Library, $rec.ItemType, `
|
||||
$rec.ItemName, $rec.ItemPath, $rec.GrantedTo, $rec.Permissions, `
|
||||
$rec.LinkType, $rec.ScannedAt
|
||||
Add-Content -Path $csvOut -Value $line -Encoding UTF8
|
||||
$script:recordCount++
|
||||
}
|
||||
}
|
||||
|
||||
$siteIndex = 0
|
||||
$total = $siteUrls.Count
|
||||
|
||||
foreach ($siteUrl in $siteUrls) {
|
||||
$siteIndex++
|
||||
$pct = [math]::Round(($siteIndex / $total) * 100)
|
||||
Write-Progress -Activity "SharePoint Permissions Audit" `
|
||||
-Status "[$siteIndex/$total] $siteUrl | Total records: $recordCount" `
|
||||
-PercentComplete $pct
|
||||
|
||||
# Resolve site URL to Graph site ID using the hostname:/path format
|
||||
try {
|
||||
$uri = [Uri]$siteUrl
|
||||
$hostPart = $uri.Host
|
||||
$pathPart = $uri.AbsolutePath.TrimStart('/')
|
||||
if ($pathPart) {
|
||||
$siteObj = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/sites/${hostPart}:/${pathPart}" -Method GET -OutputType PSObject
|
||||
} else {
|
||||
$siteObj = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/sites/${hostPart}" -Method GET -OutputType PSObject
|
||||
}
|
||||
$siteId = $siteObj.id
|
||||
$siteName = if ($siteObj.PSObject.Properties.Name -contains 'displayName' -and $siteObj.displayName) { $siteObj.displayName } else { $siteObj.name }
|
||||
} catch {
|
||||
Write-Log "[$siteIndex/$total] Failed to resolve site ID for '$siteUrl': $_" -Level 'WARNING'
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Log "[$siteIndex/$total] $siteName"
|
||||
|
||||
# Get all document libraries for this site (excludes system lists)
|
||||
try {
|
||||
$drives = Invoke-GraphGet -Uri "https://graph.microsoft.com/v1.0/sites/$siteId/drives"
|
||||
$drives = $drives | Where-Object { $_.driveType -eq 'documentLibrary' }
|
||||
} catch {
|
||||
Write-Log " Error retrieving document libraries: $_" -Level 'WARNING'
|
||||
continue
|
||||
}
|
||||
|
||||
$recordsBefore = $recordCount
|
||||
|
||||
foreach ($drive in $drives) {
|
||||
$libName = $drive.name
|
||||
$driveId = $drive.id
|
||||
Write-Log " Library: $libName"
|
||||
|
||||
# Scan library root permissions (always included)
|
||||
try {
|
||||
$root = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/drives/$driveId/root" -Method GET -OutputType PSObject
|
||||
$records = Get-ItemPermissionRecords -DriveId $driveId -ItemId $root.id `
|
||||
-ItemName $libName -ItemPath "/" -ItemType 'Library' `
|
||||
-SiteName $siteName -SiteUrl $siteUrl -LibraryName $libName -ForceInclude
|
||||
Write-RecordToCsv -Records $records
|
||||
} catch {
|
||||
Write-Log " Error scanning library root '$libName': $_" -Level 'WARNING'
|
||||
}
|
||||
|
||||
# Scan all folders (and files if -IncludeFileLevel is set)
|
||||
# Only items with unique (broken) permissions are included
|
||||
try {
|
||||
$items = Get-AllDriveItems -DriveId $driveId
|
||||
foreach ($item in $items) {
|
||||
$isFolder = $item.PSObject.Properties.Name -contains 'folder'
|
||||
$isFile = $item.PSObject.Properties.Name -contains 'file'
|
||||
if (-not $isFolder -and -not ($IncludeFileLevel -and $isFile)) { continue }
|
||||
|
||||
$itemType = if ($isFolder) { 'Folder' } else { 'File' }
|
||||
$parentPath = ''
|
||||
if ($item.PSObject.Properties.Name -contains 'parentReference' -and
|
||||
$item.parentReference.PSObject.Properties.Name -contains 'path') {
|
||||
$parentPath = $item.parentReference.path -replace '.*?/root:', ''
|
||||
}
|
||||
$cleanPath = "$parentPath/$($item.name)"
|
||||
|
||||
$records = Get-ItemPermissionRecords -DriveId $driveId -ItemId $item.id `
|
||||
-ItemName $item.name -ItemPath $cleanPath -ItemType $itemType `
|
||||
-SiteName $siteName -SiteUrl $siteUrl -LibraryName $libName
|
||||
Write-RecordToCsv -Records $records
|
||||
}
|
||||
} catch {
|
||||
Write-Log " Error scanning items in '$libName': $_" -Level 'WARNING'
|
||||
}
|
||||
}
|
||||
|
||||
$added = $recordCount - $recordsBefore
|
||||
if ($added -gt 0) {
|
||||
Write-Log " -> $added new records written (total: $recordCount)" -Level 'SUCCESS'
|
||||
}
|
||||
}
|
||||
|
||||
Write-Progress -Activity "SharePoint Permissions Audit" -Completed
|
||||
Write-Log "Scan complete. $recordCount permission records found." -Level 'SUCCESS'
|
||||
|
||||
#endregion
|
||||
|
||||
#region ── Build Excel report from CSV ───────────────────────────────────────
|
||||
# Reads the completed CSV and generates a multi-sheet Excel report.
|
||||
# Sheet 1 - All Permissions : full dataset
|
||||
# Sheet 2 - Summary by Site : record counts grouped per site
|
||||
# Sheet 3 - Top 20 Risk Items: items with the most unique permission grants
|
||||
# Sheet 4 - Sharing Links : all items shared via external/anonymous links
|
||||
|
||||
Write-Log "Building Excel report from CSV..."
|
||||
|
||||
if ($recordCount -gt 0) {
|
||||
$allRecords = Import-Csv -Path $csvOut -Delimiter ';' -Encoding UTF8
|
||||
|
||||
# Sheet 1: Full permission list
|
||||
$allRecords | Export-Excel -Path $xlsxOut -WorksheetName 'All Permissions' `
|
||||
-TableName 'TblAllPermissions' -TableStyle Medium9 -AutoSize -FreezeTopRow -BoldTopRow
|
||||
|
||||
# Sheet 2: Summary grouped by site
|
||||
$allRecords | Group-Object SiteName | Select-Object `
|
||||
@{N='Site'; E={$_.Name}},
|
||||
@{N='Total'; E={$_.Count}},
|
||||
@{N='Libraries'; E={($_.Group.Library | Sort-Object -Unique).Count}},
|
||||
@{N='Folders'; E={($_.Group | Where-Object ItemType -eq 'Folder').Count}},
|
||||
@{N='Files'; E={($_.Group | Where-Object ItemType -eq 'File').Count}} |
|
||||
Sort-Object Total -Descending |
|
||||
Export-Excel -Path $xlsxOut -WorksheetName 'Summary by Site' `
|
||||
-TableName 'TblSummary' -TableStyle Medium2 -AutoSize -FreezeTopRow -BoldTopRow -Append
|
||||
|
||||
# Sheet 3: Top 20 items with most unique grants (highest permission fragmentation risk)
|
||||
$allRecords | Group-Object ItemPath | Select-Object `
|
||||
@{N='Site'; E={($_.Group | Select-Object -First 1).SiteName}},
|
||||
@{N='Library'; E={($_.Group | Select-Object -First 1).Library}},
|
||||
@{N='Type'; E={($_.Group | Select-Object -First 1).ItemType}},
|
||||
@{N='Item'; E={($_.Group | Select-Object -First 1).ItemName}},
|
||||
@{N='Path'; E={($_.Group | Select-Object -First 1).ItemPath}},
|
||||
@{N='Unique Grants'; E={$_.Count}} |
|
||||
Sort-Object 'Unique Grants' -Descending | Select-Object -First 20 |
|
||||
Export-Excel -Path $xlsxOut -WorksheetName 'Top 20 Risk Items' `
|
||||
-TableName 'TblTopRisk' -TableStyle Medium6 -AutoSize -FreezeTopRow -BoldTopRow -Append
|
||||
|
||||
# Sheet 4: Sharing links (potential external exposure)
|
||||
$links = @($allRecords | Where-Object { $_.LinkType -ne '' })
|
||||
if ($links.Count -gt 0) {
|
||||
$links | Export-Excel -Path $xlsxOut -WorksheetName 'Sharing Links' `
|
||||
-TableName 'TblSharingLinks' -TableStyle Medium3 -AutoSize -FreezeTopRow -BoldTopRow -Append
|
||||
}
|
||||
|
||||
Write-Log "Excel report saved: $xlsxOut" -Level 'SUCCESS'
|
||||
} else {
|
||||
Write-Log "No records found - Excel report not created." -Level 'WARNING'
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Disconnect-MgGraph | Out-Null
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Cyan
|
||||
Write-Host " Scan complete!" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Records : $recordCount"
|
||||
Write-Host " CSV : $csvOut"
|
||||
Write-Host " Excel : $xlsxOut"
|
||||
Write-Host " Log : $logOut"
|
||||
Write-Host "========================================`n" -ForegroundColor Cyan
|
||||
Loading…
Reference in New Issue
Block a user