SPPermission.psm1
$script:ModuleRoot = $PSScriptRoot $script:ModuleVersion = (Import-PowerShellDataFile -Path "$($script:ModuleRoot)\SPPermission.psd1").ModuleVersion # Detect whether at some level dotsourcing was enforced $script:doDotSource = Get-PSFConfigValue -FullName SPPermission.Import.DoDotSource -Fallback $false if ($SPPermission_dotsourcemodule) { $script:doDotSource = $true } <# Note on Resolve-Path: All paths are sent through Resolve-Path/Resolve-PSFPath in order to convert them to the correct path separator. This allows ignoring path separators throughout the import sequence, which could otherwise cause trouble depending on OS. Resolve-Path can only be used for paths that already exist, Resolve-PSFPath can accept that the last leaf my not exist. This is important when testing for paths. #> # Detect whether at some level loading individual module files, rather than the compiled module was enforced $importIndividualFiles = Get-PSFConfigValue -FullName SPPermission.Import.IndividualFiles -Fallback $false if ($SPPermission_importIndividualFiles) { $importIndividualFiles = $true } if (Test-Path (Resolve-PSFPath -Path "$($script:ModuleRoot)\..\.git" -SingleItem -NewChild)) { $importIndividualFiles = $true } if ("<was compiled>" -eq '<was not compiled>') { $importIndividualFiles = $true } function Import-ModuleFile { <# .SYNOPSIS Loads files into the module on module import. .DESCRIPTION This helper function is used during module initialization. It should always be dotsourced itself, in order to proper function. This provides a central location to react to files being imported, if later desired .PARAMETER Path The path to the file to load .EXAMPLE PS C:\> . Import-ModuleFile -File $function.FullName Imports the file stored in $function according to import policy #> [CmdletBinding()] Param ( [string] $Path ) $resolvedPath = $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($Path).ProviderPath if ($doDotSource) { . $resolvedPath } else { $ExecutionContext.InvokeCommand.InvokeScript($false, ([scriptblock]::Create([io.file]::ReadAllText($resolvedPath))), $null, $null) } } #region Load individual files if ($importIndividualFiles) { # Execute Preimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\preimport.ps1")) { . Import-ModuleFile -Path $path } # Import all internal functions foreach ($function in (Get-ChildItem "$ModuleRoot\internal\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Import all public functions foreach ($function in (Get-ChildItem "$ModuleRoot\functions" -Filter "*.ps1" -Recurse -ErrorAction Ignore)) { . Import-ModuleFile -Path $function.FullName } # Execute Postimport actions foreach ($path in (& "$ModuleRoot\internal\scripts\postimport.ps1")) { . Import-ModuleFile -Path $path } # End it here, do not load compiled code below return } #endregion Load individual files #region Load compiled code <# This file loads the strings documents from the respective language folders. This allows localizing messages and errors. Load psd1 language files for each language you wish to support. Partial translations are acceptable - when missing a current language message, it will fallback to English or another available language. #> Import-PSFLocalizedString -Path "$($script:ModuleRoot)\en-us\*.psd1" -Module 'SPPermission' -Language 'en-US' enum LogLevel { None = 0 Medium = 1 Detail = 2 } function ConvertTo-UserSitePermission { <# .SYNOPSIS Match Sharepoint permissions to users through recursive group membership. .DESCRIPTION Match Sharepoint permissions to users through recursive group membership. The result expresses the effective access a user has on a site (if any) .PARAMETER UserData The processed user information as returned by Resolve-SPGraphUser. .PARAMETER InputPermission The processed sharepoint site permissions as returned by Get-SPSitePermission .PARAMETER LogLevel How detailed a log should be written. .EXAMPLE PS C:\> $sites | Get-SPSitePermission | ConvertTo-UserSitePermission -UserData $userData Retrieves all permisions for sites stored in $sites, then matches the user info in $userData against it #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0)] $UserData, [Parameter(ValueFromPipeline = $true)] $InputPermission, [LogLevel] $LogLevel = 'Medium' ) begin { $allIdentities = $UserData.Identities | Sort-Object -Unique $aadType = @{ 'AAD' = 'Group' 'AADRole' = 'Role' } } process { foreach ($permissionObject in $InputPermission) { # Skip entries that cannot be matched (Only AAD entities can be matched to an AAD user) if ($permissionObject.Type -notin 'AAD', 'AADRole') { continue } # Skip entries that have no match among our user data if ($permissionObject.ID -notin $allIdentities -and $permissionObject.Email -notin $allIdentities) { continue } foreach ($user in $UserData) { if ( $permissionObject.ID -notin $user.Identities -and $permissionObject.Email -notin $user.Identities ) { continue } $result = [PSCustomObject]@{ Site = $permissionObject.Site SiteIdentity = $permissionObject.SiteIdentity UserPrincipalName = $user.UserPrincipalName Permission = $permissionObject.Permission Through = $permissionObject.ID Type = $aadType[$permissionObject.Type] } if (-not $result.Through) { $result.Through = $permissionObject.Email } if ($LogLevel -gt 0) { Write-PSFMessage -Message "User {0} has {1} permissions through {2} on {3}" -StringValues $result.UserPrincipalName, $result.Permission, $result.Through, $result.Site -Target $result } $result } } } } function Export-CsvData { <# .SYNOPSIS Helper command that tees input objects into a CSV file. .DESCRIPTION Helper command that tees input objects into a CSV file. .PARAMETER Path The path to the file to be written to. May be empty, in which case no file will be created. .PARAMETER InputObject The object to write to CSV .EXAMPLE PS C:\> Get-ChildItem | Export-CsvData -Path C:\temp\files.csv | Where-Object Name -like "W*" Gets all files and folders in the current path, then writes all results to the specified csv file, then returns all items that start with a W. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, Position = 0)] [AllowEmptyString()] [string] $Path, [Parameter(ValueFromPipeline = $true)] $InputObject ) begin { if (-not $Path) { return } $command = { Export-Csv -Path $Path }.GetSteppablePipeline() $command.Begin($true) } process { if (-not $Path) { return $InputObject } $command.Process($InputObject) $InputObject } end { $command.End() } } function Resolve-SPGraphUserGroupMembership { <# .SYNOPSIS Resolves all user group memberships. .DESCRIPTION Resolves all user group memberships. .PARAMETER Identity UPN or ID of the user to resovle the group memebrships for. .PARAMETER Data Add groups to the hashtable specified. If this parameter is not specified, it will instead return them as objects. .PARAMETER LogLevel How detailed a log should be written. .EXAMPLE PS C:\> Resolve-SPGraphUserGroupMembership -Identity fred@contoso.onmicrosoft.com Resolves the group memberships for the user fred@contoso.onmicrosoft.com #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Identity, [Hashtable] $Data = @{ }, [LogLevel] $LogLevel = 'Medium' ) begin { $returnData = $PSBoundParameters.Keys -notcontains 'Data' } process { foreach ($membership in Invoke-EntraRequest -Path "users/$Identity/transitiveMemberOf") { if ($LogLevel -gt 0) { Write-PSFMessage -Message 'User {0} is in group {1} ({2})' -StringValues $Identity, $membership.DisplayName, $membership.ID } $Data[$membership.id] = [PSCustomObject]$membership } if ($returnData) { $Data.Values } } } function Select-Site { <# .SYNOPSIS Switches the current connection context of Sharepoint to the specified site. .DESCRIPTION Switches the current connection context of Sharepoint to the specified site. This reuses the previously specified connection information. .PARAMETER Url The site url to switch to. .EXAMPLE PS C:\> Select-Site -Url https://contoso.sharepoint.com/Sites/WorldConquestv4 Switches the current connection context to the specified site. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $Url ) if (-not $script:thumbprint) { throw "Not connected yet, call 'Connect-Sharepoint' to connect!" } Connect-PnPOnline -Tenant $script:tenantID -ClientId $script:clientID -Thumbprint $script:thumbprint -TenantAdminUrl $script:adminUrl -Url $Url -ReturnConnection } function Connect-Sharepoint { <# .SYNOPSIS Connects to Sharepoint Online and MSGraph. .DESCRIPTION Connects to Sharepoint Online and MSGraph. Uses certificate-based authentication only. Scopes needed for operations covered under this module: > Graph User.Read.All Group.Read.All > Sharepoint Sites.FullControl.All (do not confuse this with the Graph permission of the same name!) .PARAMETER TenantID Id of the tenant to connect to. .PARAMETER ClientID Client application ID of the App Registration to use. .PARAMETER Thumbprint Thumbprint of the certificate to use for authentication .PARAMETER AdminUrl Admin URL of your tenant's sharepoint sites. In most cases, if your tenant is "contoso.onmicrosoft.com" this link would be: https://contoso-admin.sharepoint.com .EXAMPLE PS C:\> Connect-Sharepoint -TenantID $TenantID -ClientID $ClientID -Thumbprint $Thumbprint -AdminUrl 'https://contoso-admin.sharepoint.com' Connects to the contoso tenant's Sharepoint and MSGraph, for the explicit purpose of doing evil (and scanning permissions) #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $TenantID, [Parameter(Mandatory = $true)] [string] $ClientID, [Parameter(Mandatory = $true)] [string] $Thumbprint, [Parameter(Mandatory = $true)] [string] $AdminUrl ) Connect-PnPOnline -Tenant $TenantID -ClientId $ClientID -Thumbprint $Thumbprint -TenantAdminUrl $AdminUrl -Url $AdminUrl $null = Connect-EntraService -TenantId $TenantID -ClientId $ClientID -CertificateThumbprint $Thumbprint $script:tenantID = $TenantID $script:clientID = $ClientID $script:thumbprint = $Thumbprint $script:adminUrl = $AdminUrl } function Connect-SPScc { <# .SYNOPSIS Establishes a Modern Auth connection with the Security & Compliance Center. .DESCRIPTION Establishes a Modern Auth connection with the Security & Compliance Center. Behind the scenes, it uses the ExchangeOnlineManagement module's Connect-ExchangeOnline command. .PARAMETER AzureADAuthorizationEndpointUri The AzureADAuthorizationEndpointUri parameter specifies the Azure AD Authorization endpoint Uri that can issue OAuth2 access tokens. .PARAMETER ExchangeEnvironmentName The ExchangeEnvironmentName specifies the Exchange Online environment. Valid values are: - O365China - O365Default (this is the default value) - O365GermanyCloud - O365USGovDoD - O365USGovGCCHigh .PARAMETER PSSessionOption The PSSessionOption parameter specifies the PowerShell session options to use in your connection to SCC. Use the "New-PSSessionOption" cmdlet to generate them. Useful for example to configure a proxy. .PARAMETER BypassMailboxAnchoring The BypassMailboxAnchoring switch bypasses the use of the mailbox anchoring hint. .PARAMETER DelegatedOrganization The DelegatedOrganization parameter specifies the customer organization that you want to manage (for example, contosoelectronics.onmicrosoft.com). This parameter only works if the customer organization has agreed to your delegated management via the CSP program. After you successfully authenticate, the cmdlets in this session are mapped to the customer organization, and all operations in this session are done on the customer organization. .PARAMETER Prefix Add a module prefix to the imported commands. .PARAMETER UserPrincipalName The UserPrincipalName parameter specifies the account that you want to use to connect (for example, fred@contoso.onmicrosoft.com). Using this parameter allows you to skip the first screen in authentication prompt. .PARAMETER Credential The credentials to use when connecting to SCC. Needed for unattended automation. If this parameter is omitted, you will be prompted interactively. .EXAMPLE PS C:\> Connect-SPScc Connects to the Securit & Compliance Center #> [Alias('cscc')] [CmdletBinding()] param ( [string] $AzureADAuthorizationEndpointUri, [Microsoft.Exchange.Management.RestApiClient.ExchangeEnvironment] $ExchangeEnvironmentName, [System.Management.Automation.Remoting.PSSessionOption] $PSSessionOption, [switch] $BypassMailboxAnchoring, [string] $DelegatedOrganization, [string] $Prefix, [string] $UserPrincipalName, [PSCredential] $Credential ) begin { $settings = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WinRM\Client\' -ErrorAction Ignore if ($settings -and 0 -eq $settings.AllowBasic) { throw 'Logon impossible, client policies prevent connection.' } if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$null)) { $PSBoundParameters['OutBuffer'] = 1 } $parameters = @{ ShowBanner = $false ConnectionUri = 'https://ps.compliance.protection.outlook.com/powershell-liveid/' } $parameters += $PSBoundParameters | ConvertTo-PSFHashtable try { $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Connect-ExchangeOnline', [System.Management.Automation.CommandTypes]::Function) $scriptCmd = { & $wrappedCmd @parameters } $steppablePipeline = $scriptCmd.GetSteppablePipeline() $steppablePipeline.Begin($PSCmdlet) } catch { throw } } process { try { $steppablePipeline.Process($_) } catch { throw } } end { try { $steppablePipeline.End() } catch { throw } } } function Get-SPSitePermission { <# .SYNOPSIS Resolve the effective permissions on the specified sites. .DESCRIPTION Resolve the effective permissions on the specified sites. .PARAMETER Url Url to the sharepoint site to scan. .PARAMETER Exclude What kinds of principals to ignore By default, System, Sharepoint Roles (SPRole) and Sharepoint Users (SPUser) are ignored. .PARAMETER LogLevel How detailed a log should be written. .EXAMPLE PS C:\> Get-PnPTenanteSite | Get-SPSitePermission Retrieve all permissions for all sites in the tenant. #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $Url, [ValidateSet('None', 'System', 'SPRole', 'SPUser', 'AAD', 'AADRole', 'Unknown')] [string[]] $Exclude = @('System', 'SPRole', 'SPUser'), [LogLevel] $LogLevel = 'Medium' ) begin { if ($Exclude -contains 'None') { $Exclude = 'None' } } process { foreach ($link in $Url) { if ($LogLevel -gt 0) { Write-PSFMessage 'Retrieving permissions from site {0}' -StringValues $link } try { $session = Select-Site -Url $link } catch { Write-PSFMessage -Level Warning -Message 'Failed to connect to site: {0}' -StringValues $link -ErrorRecord $_ -Target $link } try { $web = Get-PnPWeb -Includes RoleAssignments -Connection $session -ErrorAction Stop } catch { if ($_ -like "*(404)*") { continue } Write-PSFMessage -Level Warning -Message 'Failed to access site: {0}' -StringValues $link -ErrorRecord $_ -Target $link continue } foreach ($roleAssignment in $web.RoleAssignments) { $members = $roleAssignment.Member Invoke-PSFProtectedCommand -Action "Retrieving properties for a member of roleassignment to site: $link" -Target $roleAssignment -ScriptBlock { $null = Get-PnPProperty -ClientObject $members -Property LoginName, ID, PrincipalType -Connection $session -ErrorAction Stop $null = Get-PnPProperty -ClientObject $roleAssignment -Property RoleDefinitionBindings -Connection $session -ErrorAction Stop } -PSCmdlet $PSCmdlet -EnableException $false -Continue -RetryCount 3 -RetryWait 1 foreach ($member in $members) { if ($LogLevel -gt 1) { Write-PSFMessage -Message "Processing $($member.LoginName) | $($member.TypedObject.ToString())" -Target $member } switch ($member.TypedObject.ToString()) { 'Microsoft.SharePoint.Client.User' { if ($Exclude -contains 'SPUser') { break } [PSCustomObject]@{ Site = $link SiteIdentity = $member.LoginName Permission = $roleAssignment.RoleDefinitionBindings.Name Title = $null LoginName = $null EMail = $null ID = $null Type = 'SPUser' } } 'Microsoft.SharePoint.Client.Group' { $count = 0 do { try { $spGroupMembers = Get-PnPGroupMember -Group $member.Id -Connection $session -ErrorAction Stop break } catch { $lastError = $_ $count++ } } while ($count -lt 5) if ($count -ge 5 -and -not $spGroupMembers) { Write-PSFMessage -Level Warning -Message "Failed to retrieve members of group $($group.Id)" -ErrorRecord $lastError } foreach ($spGroupMember in $spGroupMembers) { #region Process individual memberships #region System if ($spGroupMember.LoginName -like "SHAREPOINT\*") { if ($Exclude -contains 'System') { continue } [PSCustomObject]@{ Site = $link SiteIdentity = $member.LoginName Permission = $roleAssignment.RoleDefinitionBindings.Name Title = $spGroupMember.Title LoginName = $spGroupMember.LoginName EMail = $spGroupMember.Email ID = $null Type = 'System' } continue } #endregion System #region SPRole if ($spGroupMember.LoginName -like "*|rolemanager|*") { if ($Exclude -contains 'SPRole') { continue } [PSCustomObject]@{ Site = $link SiteIdentity = $member.LoginName Permission = $roleAssignment.RoleDefinitionBindings.Name Title = $spGroupMember.Title LoginName = $spGroupMember.LoginName EMail = $spGroupMember.Email ID = $spGroupMember.LoginName.Split('|')[-1] Type = 'SPRole' } continue } #endregion SPRole #region AAD if ($spGroupMember.LoginName -like "*|federateddirectoryclaimprovider|*") { if ($Exclude -contains 'AAD') { continue } [PSCustomObject]@{ Site = $link SiteIdentity = $member.LoginName Permission = $roleAssignment.RoleDefinitionBindings.Name Title = $spGroupMember.Title LoginName = $spGroupMember.LoginName EMail = $spGroupMember.Email ID = $spGroupMember.LoginName.Split('|')[-1] -replace '_o$' Type = 'AAD' } continue } #endregion AAD #region AADRole if ($spGroupMember.LoginName -like "*|tenant|*") { if ($Exclude -contains 'AADRole') { continue } [PSCustomObject]@{ Site = $link SiteIdentity = $member.LoginName Permission = $roleAssignment.RoleDefinitionBindings.Name Title = $spGroupMember.Title LoginName = $spGroupMember.LoginName EMail = $spGroupMember.Email ID = $spGroupMember.LoginName.Split('|')[-1] Type = 'AADRole' } continue } #endregion AADRole #region Unknown if ($Exclude -contains 'Unknown') { continue } [PSCustomObject]@{ Site = $link SiteIdentity = $member.LoginName Permission = $roleAssignment.RoleDefinitionBindings.Name Title = $spGroupMember.Title LoginName = $spGroupMember.LoginName EMail = $spGroupMember.Email ID = $null Type = 'Unknown' } #endregion Unknown #endregion Process individual memberships } } } } } } } } function Invoke-SPPermissionScan { <# .SYNOPSIS Executes a managed and logged sharepoint permission scan. .DESCRIPTION Executes a managed and logged sharepoint permission scan. This scan then matches the result against specified users, resolving the effective access rights of a user. This includes simplified logging options (for more options, see https://psframework.org). It can also at stages export data to csv as it comes in. This command is designed to be able to handle any Sharepoint site scale. More sites means longer runtimes, but should not affect quality of result. Connect via Connect-Sharepoint before running this command. .PARAMETER UserIdentity IDs or UserPrincipalName of users to match against the Sharepoint permissions. Group memberships are taken into account recursively. .PARAMETER SiteUrl Sharepoint sites to scan. If this parameter is not specified, all Sharepoint sites in the tenant will be scanned. .PARAMETER LogPath The path to the file into which the command should log actions. The output will be in a CSV format and include detailed information on what information was retrieved against what site. .PARAMETER ExportPathRaw Path to which raw Sharepoint permission results should be written as CSV. Specify this path if you want an export of all permissions in their raw state without matching it against users. .PARAMETER ExportPathResult Path to which processed permission results should be written as CSV. This content is equal to the output objects of this command. .PARAMETER RawExclude Whether the raw export should exclude specific permissions. By default, Sharepoint internal permissions are excluded. .PARAMETER LogLevel How detailed a log should be written. .EXAMPLE PS C:\> Invoke-SPPermissionScan -UserIdentity (Get-Content .\users.txt) Scan all sharepoint sites for their permissions and match them against the users stored in the "users.txt" text file. #> [CmdletBinding()] param ( [string[]] $UserIdentity, [Parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [string[]] $SiteUrl, [string] $LogPath, [PsfValidateScript('PSFramework.Validate.FSPath.FileOrParent', ErrorString = 'PSFramework.Validate.FSPath.FileOrParent')] [string] $ExportPathRaw, [PsfValidateScript('PSFramework.Validate.FSPath.FileOrParent', ErrorString = 'PSFramework.Validate.FSPath.FileOrParent')] [string] $ExportPathResult, [ValidateSet('None', 'System', 'SPRole', 'SPUser', 'AAD', 'AADRole', 'Unknown')] [string[]] $RawExclude = @('System', 'SPRole', 'SPUser'), [LogLevel] $LogLevel = 'Medium' ) begin { if (-not $script:adminUrl) { throw "Not connected yet, call 'Connect-Sharepoint' to connect!" } Connect-Sharepoint -TenantID $script:TenantID -ClientID $script:ClientID -Thumbprint $script:Thumbprint -AdminUrl $script:adminUrl if ($LogPath) { Set-PSFLoggingProvider -Name logfile -InstanceName SPPermissionScan -FilePath $LogPath -Enabled $true } else { $LogLevel = 'None' } $userData = Resolve-SPGraphUser -Identity $UserIdentity -LogLevel $LogLevel Write-PSFMessage -Level Host -Message 'Starting Sharepoint Permission Scan' -Tag progress } process { if ($SiteUrl) { $SiteUrl | Get-SPSitePermission -Exclude $RawExclude -LogLevel $LogLevel | Export-CsvData -Path $ExportPathRaw | ConvertTo-UserSitePermission -UserData $userData -LogLevel $LogLevel | Export-CsvData -Path $ExportPathResult } else { $urlCache = Join-Path -Path (Get-PSFPath -Name Temp) -ChildPath "ssp-$(Get-Random).txt" try { # Get-PnPTenantSite keeps all sites in memory before returning them # Writing them to file and then reading from file minimizes memory impact Get-PnPTenantSite | ForEach-Object Url | Set-Content -Path $urlCache Get-Content -Path $urlCache | Get-SPSitePermission -Exclude $RawExclude -LogLevel $LogLevel | Export-CsvData -Path $ExportPathRaw | ConvertTo-UserSitePermission -UserData $userData -LogLevel $LogLevel | Export-CsvData -Path $ExportPathResult } finally { Remove-Item -Path $urlCache -Force -ErrorAction Ignore } } } end { Write-PSFMessage -Level Host -Message 'Sharepoint Permission Scan Concluded' -Tag progress if ($LogPath) { Wait-PSFMessage Set-PSFLoggingProvider -Name logfile -InstanceName SPPermissionScan -Enabled $false } Connect-Sharepoint -TenantID $script:TenantID -ClientID $script:ClientID -Thumbprint $script:Thumbprint -AdminUrl $script:adminUrl } } function New-SPCase { <# .SYNOPSIS Create a new eDiscovery case and add searches for the locations identified. .DESCRIPTION Create a new eDiscovery case and add searches for the locations identified. Users the user-associated site permissions returned by Invoke-SPPermissionScan or ConvertTo-UserSitePermission commands to dynamically generate searches. Note: Technically this supports advanced eDiscovery, but the search will not be accessible through the UI. .PARAMETER PermissionData Data generated by Invoke-SPPermissionScan or ConvertTo-UserSitePermission. Technically accepts any objects that contain two properties: - UserPrincipalName - Site With "Site" containing the sharepoint site Url. .PARAMETER Case Name of the case to create or use. Adds searches to an existing case if present. .PARAMETER Search Name of the search to generate. Searches have their case-name prepended to ensure uniqueness. .PARAMETER Type Whether to use basic or advanced eDiscovery. Defaults to basic, which is for now the recommended way to create searches. .PARAMETER CaseMode Create one case in total or one per user? Note: When selecting per user, the same site may be searched multiple times. Defaults to: Bulk .PARAMETER SearchMode Create one search in total or one per user? (or none at all?) Note: When selecting per user, the same site may be searched multiple times. Defaults to: Bulk .PARAMETER HoldMode Create holds for the identified sites. One in total, one per user? Note: When selecting per user, the same site may be put on hold multiple times. Multiple holds do not conflict, but ALL of them must be lifted for the site to be released. Defaults to: None .PARAMETER SitesPerSearch How many sites to add to a single search. If set to higher than 0, each search will have its index number appended to the name. .PARAMETER SitesPerHold How many sites to add to a single hold. Defaults to 100, cannot be larger than 100. .EXAMPLE PS C:\> New-SPCase -PermissionData $permissions -Case contoso_test -Search Test Creates a new case named 'contoso_test' for the permissions scanned in $permissions. .EXAMPLE PS C:\> New-SPCase -PermissionData (Import-Csv .\export-result.csv) -Case contoso_test -Search Test -SearchMode PerUser -SitesPerSearch 40 Creates a new case named 'contoso_test' It uses the results of the permission scan stored in "export-result.csv" to generate searches. For each user, a separate set of searches will be created (which may include some site duplication). For each user, the total set of applicable sites will be split into sets of no more than 40 sites per search. .EXAMPLE PS C:\> New-SPCase -PermissionData (Import-Csv .\export-result.csv) -Case contoso_test -Search Test -SearchMode None -HoldMode Bulk Creates a new case named 'contoso_test' It uses the results of the permission scan stored in "export-result.csv" to generate a hold over all sites found. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $PermissionData, [Parameter(Mandatory = $true)] [string] $Case, [string] $Search = 'Sharepoint', [ValidateSet('Advanced', 'Basic')] [string] $Type = 'Basic', [ValidateSet('Bulk', 'PerUser')] [string] $CaseMode = 'Bulk', [ValidateSet('Bulk', 'PerUser', 'None')] [string] $SearchMode = 'Bulk', [ValidateSet('Bulk', 'PerUser', 'None')] [string] $HoldMode = 'None', [int] $SitesPerSearch = -1, [ValidateRange(1,100)] [int] $SitesPerHold = 100 ) begin { #region Utility Functions function New-Case { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] $PermissionData, [ValidateSet('Bulk', 'PerUser', 'None')] [string] $SearchMode, [ValidateSet('Bulk', 'PerUser', 'None')] [string] $HoldMode, [int] $SitesPerSearch, [int] $SitesPerHold, [string] $Search, [ValidateSet('Advanced', 'Basic')] [string] $Type, [string] $Name ) begin { $typeHash = @{ Advanced = 'AdvancedEdiscovery' Basic = 'eDiscovery' } } process { $caseObject = Get-ComplianceCase -Identity $Name -ErrorAction Ignore if (-not $caseObject) { $caseObject = New-ComplianceCase -Name $Name -CaseType $typeHash[$Type] } switch ($SearchMode) { Bulk { New-Search -Name $Search -PermissionData $PermissionData -SitesPerSearch $SitesPerSearch -CaseObject $caseObject } PerUser { foreach ($group in $PermissionData | Group-Object UserPrincipalName) { New-Search -Name "$($Search)_$($group.Name -replace '@','_')" -PermissionData $group.Group -SitesPerSearch $SitesPerSearch -CaseObject $caseObject } } } switch ($HoldMode) { Bulk { New-Hold -Name $Search -PermissionData $PermissionData -CaseObject $caseObject -SitesPerHold $SitesPerHold } PerUser { foreach ($group in $PermissionData | Group-Object UserPrincipalName) { New-Hold -Name "$($Search)_$($group.Name -replace '@','_')" -PermissionData $group.Group -CaseObject $caseObject -SitesPerHold $SitesPerHold } } } } } function New-Hold { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( [string] $Name, $PermissionData, $CaseObject, [int] $SitesPerHold ) $sites = @($PermissionData.Site | Sort-Object -Unique) $index = 0 $count = 0 do { $policy = $null $count++ $holdName = '{0}_{1}' -f $Name, $count Write-PSFMessage -Message 'Creating hold: {0}' -StringValues $holdName $length = $SitesPerHold if (($sites.Count - $index) -lt $length) { $length = $sites.Count - $index } $endIndex = $index + $length - 1 $currentSites = $sites[$index..$endIndex] <# Creating a Hold Policy will fail if the sites no longer exist or are in readonly mode. Even if only one site in the list is affected, it will fail. So we parse the error for the bad site, remove it from the list and try again, until either: - No bad site is in the list - No sites remain - Another, unrelated error occurs #> while ($true) { # Case: All sites were deleted/readonly if (-not $currentSites) { break} try { $policy = New-CaseHoldPolicy -Name $holdName -Case $CaseObject.Identity -SharePointLocation $currentSites -ErrorAction Stop break } catch { $wasNotFound = $_ -match 'No exact match was found' $wasReadOnly = $_ -match 'locked in ReadOnly mode' if (-not ($wasNotFound -or $wasReadOnly)) { Stop-PSFFunction -Message 'Unexpected Error' -ErrorRecord $_ -EnableException $true -Cmdlet $PSCmdlet } $siteLink = @(($_ | Select-String '(https://[^\s"]+)').Matches.Groups)[1].Value if ($wasNotFound) { Write-PSFMessage -Level Warning -Message 'Site no longer exists: {0}' -StringValues $siteLink } if ($wasReadOnly) { Write-PSFMessage -Level Warning -Message 'Site is read only: {0}' -StringValues $siteLink } $currentSites = $currentSites | Where-Object { $_ -ne $siteLink } } } if ($policy) { $null = New-CaseHoldRule -Name $holdName -Policy $policy.Guid } $index = $index + $length } while ($index -lt $sites.Count) } function New-Search { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] [CmdletBinding()] param ( [string] $Name, $PermissionData, [int] $SitesPerSearch, $CaseObject ) $sites = $PermissionData.Site | Sort-Object -Unique if ($SitesPerSearch -lt 1) { New-ComplianceSearch -Name "$($CaseObject.Name)_$($Name)" -Case $CaseObject.Name -SharePointLocation $sites return } $index = @{ Number = 0 } foreach ($set in $sites | Group-Object { $index.Number++; ('{0}' -f (($index.Number - 1) / $SitesPerSearch) -replace '\.\d+$' -as [int]) + 1 }) { New-ComplianceSearch -Name "$($CaseObject.Name)_$($Name)_$($set.Name)" -Case $CaseObject.Name -SharePointLocation $set.Group } } #endregion Utility Functions } process { switch ($CaseMode) { Bulk { $param = $PSBoundParameters | ConvertTo-PSFHashtable -Include PermissionData, SearchMode, SitesPerSearch, SitesPerHold, Search, Type, HoldMode -Inherit New-Case @param -Name $Case } PerUser { foreach ($group in $PermissionData | Group-Object UserPrincipalName) { $param = $PSBoundParameters | ConvertTo-PSFHashtable -Include SearchMode, SitesPerSearch, SitesPerHold, Search, Type, HoldMode -Inherit $param.PermissionData = $group.Group New-Case @param -Name "$($Case)_$($group.Name -replace '@','_')" } } } } } function Resolve-SPGraphUser { <# .SYNOPSIS Resolves information about the specified user identifiers. .DESCRIPTION Resolves information about the specified user identifiers. This specifically will return all groups the user is a member of - directly or nested. .PARAMETER Identity UPN or ID of the users to resolve. .PARAMETER LogLevel How detailed a log should be written. .EXAMPLE PS C:\> Resolve-SPGraphUser -Identity fred@contoso.onmicrosoft.com Resolves the user fred@contoso.onmicrosoft.com #> [CmdletBinding()] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [Alias('id', 'upn', 'UserPrincipalName')] [string[]] $Identity, [LogLevel] $LogLevel = 'Medium' ) process { foreach ($name in $Identity) { if ($LogLevel -gt 0) { Write-PSFMessage -Message 'Resolving user {0}' -StringValues $name -Target $name } try { $userHash = Invoke-EntraRequest -Path "users/$($name)?`$select=userPrincipalName,displayName,id" -ErrorAction Stop } catch { Write-PSFMessage -Level Warning -Message 'Unable to retrieve user {0}' -StringValues $name -ErrorRecord $_ -Target $name -PSCmdlet $PSCmdlet continue } if (-not $userHash.id) { Write-PSFMessage -Level Warning -Message 'Unexpected result for {0}. Response in Target.' -StringValues $name -Tag unexpected -Target $userHash continue } $userHash = $userHash | ConvertTo-PSFHashtable $userHash.Groups = @{ } $userHash.Identities = @() Resolve-SPGraphUserGroupMembership -Identity $userHash.id -Data $userHash.Groups -LogLevel $LogLevel $userObject = [PSCustomObject]$userHash $userObject.Identities = @($userObject.userPrincipalName) + @($userObject.Groups.Values.Where{$_.UserPrincipalName}.UserPrincipalName) + @($userObject.Groups.Values.Where{$_.Mail}.Mail) + @($userObject.Groups.Values.Id) if ($LogLevel -gt 0) { Write-PSFMessage -Message 'User is member in {0} groups (directly or indirectly)' -StringValues $name -Target $name } $userObject } } } <# This is an example configuration file By default, it is enough to have a single one of them, however if you have enough configuration settings to justify having multiple copies of it, feel totally free to split them into multiple files. #> <# # Example Configuration Set-PSFConfig -Module 'SPPermission' -Name 'Example.Setting' -Value 10 -Initialize -Validation 'integer' -Handler { } -Description "Example configuration setting. Your module can then use the setting using 'Get-PSFConfigValue'" #> Set-PSFConfig -Module 'SPPermission' -Name 'Import.DoDotSource' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be dotsourced on import. By default, the files of this module are read as string value and invoked, which is faster but worse on debugging." Set-PSFConfig -Module 'SPPermission' -Name 'Import.IndividualFiles' -Value $false -Initialize -Validation 'bool' -Description "Whether the module files should be imported individually. During the module build, all module code is compiled into few files, which are imported instead by default. Loading the compiled versions is faster, using the individual files is easier for debugging and testing out adjustments." <# Stored scriptblocks are available in [PsfValidateScript()] attributes. This makes it easier to centrally provide the same scriptblock multiple times, without having to maintain it in separate locations. It also prevents lengthy validation scriptblocks from making your parameter block hard to read. Set-PSFScriptblock -Name 'SPPermission.ScriptBlockName' -Scriptblock { } #> <# # Example: Register-PSFTeppScriptblock -Name "SPPermission.alcohol" -ScriptBlock { 'Beer','Mead','Whiskey','Wine','Vodka','Rum (3y)', 'Rum (5y)', 'Rum (7y)' } #> <# # Example: Register-PSFTeppArgumentCompleter -Command Get-Alcohol -Parameter Type -Name SPPermission.alcohol #> New-PSFLicense -Product 'SPPermission' -Manufacturer 'Friedrich Weinmann' -ProductVersion $script:ModuleVersion -ProductType Module -Name MIT -Version "1.0.0.0" -Date (Get-Date "2021-12-08") -Text @" Copyright (c) 2021 Friedrich Weinmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "@ #endregion Load compiled code |