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