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`
|
- `get-mailbox-with-permissions.ps1`
|
||||||
- `scripts/m365/identity/`
|
- `scripts/m365/identity/`
|
||||||
- `m365-users-mfa.ps1`
|
- `m365-users-mfa.ps1`
|
||||||
|
- `scripts/m365/sharepoint/`
|
||||||
|
- `get-sharepoint-permissions.ps1`
|
||||||
- `scripts/windows/autopilot/`
|
- `scripts/windows/autopilot/`
|
||||||
- `Get-WindowsAutoPilotInfo.ps1`
|
- `Get-WindowsAutoPilotInfo.ps1`
|
||||||
- `scripts/windows/endpoint/`
|
- `scripts/windows/endpoint/`
|
||||||
@ -32,7 +34,7 @@ Collection of operational scripts for Microsoft 365, Windows endpoint management
|
|||||||
|
|
||||||
## Important Note
|
## 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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ This overview helps update guides after the repository restructuring.
|
|||||||
|
|
||||||
## Important
|
## Important
|
||||||
|
|
||||||
- Script filenames were not changed.
|
- Legacy script filenames were not changed.
|
||||||
- Only folder locations were updated.
|
- Only folder locations were updated.
|
||||||
|
|
||||||
## Script Locations
|
## 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`
|
- New path: `scripts/m365/exchange/get-mailbox-with-permissions.ps1`
|
||||||
- `m365-users-mfa.ps1`
|
- `m365-users-mfa.ps1`
|
||||||
- New path: `scripts/m365/identity/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`
|
- `schedule_reboot.ps1`
|
||||||
- New path: `scripts/windows/endpoint/schedule_reboot.ps1`
|
- New path: `scripts/windows/endpoint/schedule_reboot.ps1`
|
||||||
- `uninstall-MS-Visual-C++-2010.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