Src/Public/Invoke-AsBuiltReport.Microsoft.SharePoint.ps1

function Invoke-AsBuiltReport.Microsoft.SharePoint {
    <#
    .SYNOPSIS
        PowerShell script to document the configuration of Microsoft SharePoint Online
        and OneDrive for Business in Word/HTML/Text formats.
    .DESCRIPTION
        Documents the configuration of SharePoint Online and OneDrive for Business
        in Word/HTML/Text formats using PScribo.

        Covers:
          - Tenant Overview (identity, licensing, storage quota)
          - Sharing Policy (external sharing levels, Anyone links, domain restrictions)
          - External Access Controls (unmanaged devices, idle session, IP restrictions)
          - Site Collections (inventory, storage, owner, inactivity)
          - OneDrive for Business (quotas, sync restrictions, per-user summary)
          - Compliance & Data Governance (audit log, DLP, retention, sensitivity labels)

        Compliance Frameworks:
          - ACSC Essential Eight (ML1-ML3) -- SharePoint/OneDrive controls
          - CIS Microsoft 365 Foundations Benchmark v6.0.1 -- Chapter 7 controls

        Excel Export:
          After report generation, an Excel workbook is automatically exported
          containing sheets for all major data sets for audit and remediation tracking.

    .NOTES
        Version: 0.1.0
        Author: Pai Wei Sing
        Github: weising26

    .LINK
        https://github.com/AsBuiltReport/AsBuiltReport.Microsoft.SharePoint

    .PARAMETER Target
        Specifies the Microsoft 365 tenant domain name (e.g. contoso.onmicrosoft.com).

    .PARAMETER Credential
        Optional PSCredential. The username is used as the UPN if
        Options.UserPrincipalName is not set in the report config JSON.
    #>


    param (
        [String[]] $Target,
        [PSCredential] $Credential
    )

    Write-ReportModuleInfo -ModuleName 'Microsoft.SharePoint'

    #---------------------------------------------------------------------------------------------#
    # Dependency Module Version Check #
    #---------------------------------------------------------------------------------------------#
    $ModuleArray = @('AsBuiltReport.Core', 'Microsoft.Graph', 'PnP.PowerShell', 'ImportExcel')

    foreach ($Module in $ModuleArray) {
        try {
            $InstalledVersion = Get-Module -ListAvailable -Name $Module -ErrorAction SilentlyContinue |
                Sort-Object -Property Version -Descending |
                Select-Object -First 1 -ExpandProperty Version

            if ($InstalledVersion) {
                Write-Host " - $Module module v$($InstalledVersion.ToString()) is currently installed."

                $PSGetAvailable = Get-Module -ListAvailable -Name PowerShellGet -ErrorAction SilentlyContinue
                if ($PSGetAvailable) {
                    try {
                        Import-Module PowerShellGet -ErrorAction SilentlyContinue
                        $LatestVersion = Find-Module -Name $Module -Repository PSGallery -ErrorAction SilentlyContinue |
                            Select-Object -ExpandProperty Version
                        if ($LatestVersion -and $InstalledVersion -lt $LatestVersion) {
                            Write-Host " - $Module module v$($LatestVersion.ToString()) is available." -ForegroundColor Red
                            Write-Host " - Run 'Update-Module -Name $Module -Force' to install the latest version." -ForegroundColor Red
                        }
                    } catch {
                        Write-PScriboMessage -IsWarning "Unable to check latest version for ${Module}: $($_.Exception.Message)"
                    }
                }
            } else {
                $OptionalModules = @('ImportExcel', 'PnP.PowerShell')
                $IsOptional = $OptionalModules -contains $Module
                $Required   = if ($Module -eq 'ImportExcel') { '(Optional -- required for Excel export)' } `
                              elseif ($Module -eq 'PnP.PowerShell') { '(Optional but highly recommended -- required for SharePoint tenant settings and site collection data)' } `
                              else { '' }
                $Color = if ($IsOptional) { 'Yellow' } else { 'Red' }
                Write-Host " - $Module module is NOT installed. $Required Run 'Install-Module -Name $Module -Force'." -ForegroundColor $Color
            }
        } catch {
            Write-PScriboMessage -IsWarning $_.Exception.Message
        }
    }

    #---------------------------------------------------------------------------------------------#
    # PRE-FLIGHT: Import Graph sub-modules #
    #---------------------------------------------------------------------------------------------#
    $script:RequiredGraphModules = @(
        'Microsoft.Graph.Authentication'
        'Microsoft.Graph.Identity.DirectoryManagement'
        'Microsoft.Graph.Users'
    )
    Write-Host " - Pre-loading Microsoft Graph sub-modules..." -ForegroundColor Cyan
    foreach ($GraphModule in $script:RequiredGraphModules) {
        if (Get-Module -Name $GraphModule -ErrorAction SilentlyContinue) {
            Write-Host " - Already loaded: $GraphModule" -ForegroundColor DarkGray
            continue
        }
        try {
            Import-Module $GraphModule -Global -ErrorAction Stop
            Write-Host " - Loaded: $GraphModule" -ForegroundColor DarkGray
        } catch {
            Write-Warning " [!] Could not load ${GraphModule}: $($_.Exception.Message)"
        }
    }

    #---------------------------------------------------------------------------------------------#
    # Import Report Configuration #
    #---------------------------------------------------------------------------------------------#
    $ReportConfig.Report.Name = 'Microsoft SharePoint & OneDrive As Built Report'

    $script:Report    = $ReportConfig.Report
    $script:InfoLevel = $ReportConfig.InfoLevel
    $script:Options   = $ReportConfig.Options
    $script:TextInfo  = (Get-Culture).TextInfo
    $script:SectionTimers = [System.Collections.Generic.Dictionary[string, object]]::new()

    # Compliance framework toggles
    $script:IncludeACSCe8      = ($script:Options.ComplianceFrameworks.ACSCe8 -eq $true)
    $script:IncludeCISBaseline = ($script:Options.ComplianceFrameworks.CISBaseline -eq $true)

    if ($script:IncludeACSCe8)      { Write-Host " - Compliance framework: ACSC Essential Eight enabled"   -ForegroundColor Cyan }
    if ($script:IncludeCISBaseline) { Write-Host " - Compliance framework: CIS Baseline enabled"           -ForegroundColor Cyan }
    if (-not $script:IncludeACSCe8 -and -not $script:IncludeCISBaseline) {
        Write-Host " - Compliance frameworks: none enabled (set Options.ComplianceFrameworks in JSON to enable)" -ForegroundColor DarkGray
    }

    # Load compliance check definitions from JSON
    Initialize-AbrSPComplianceFrameworks

    # Excel export sheet collector
    $script:ExcelSheets  = [System.Collections.Specialized.OrderedDictionary]::new()
    $script:E8AllChecks  = [System.Collections.ArrayList]::new()
    $script:CISAllChecks = [System.Collections.ArrayList]::new()

    #---------------------------------------------------------------------------------------------#
    # Transcript Log Setup #
    #---------------------------------------------------------------------------------------------#
    $script:TranscriptOutputFolder = $null
    if ($ReportConfig.Report.OutputFolderPath -and (Test-Path $ReportConfig.Report.OutputFolderPath -ErrorAction SilentlyContinue)) {
        $script:TranscriptOutputFolder = $ReportConfig.Report.OutputFolderPath
    } elseif ($OutputFolderPath -and (Test-Path $OutputFolderPath -ErrorAction SilentlyContinue)) {
        $script:TranscriptOutputFolder = $OutputFolderPath
    } else {
        $script:TranscriptOutputFolder = Join-Path $env:USERPROFILE 'Documents'
    }

    $TranscriptTimestamp    = Get-Date -Format 'yyyyMMdd_HHmmss'
    $script:TranscriptLogPath = Join-Path $script:TranscriptOutputFolder "SharePoint_Transcript_${TranscriptTimestamp}.log"
    $script:ErrorLogPath      = Join-Path $script:TranscriptOutputFolder "SharePoint_Errors_${TranscriptTimestamp}.log"

    try {
        Start-Transcript -Path $script:TranscriptLogPath -Append -ErrorAction Stop | Out-Null
        Write-Host " - Transcript log: $script:TranscriptLogPath" -ForegroundColor Cyan
    } catch {
        Write-Warning " - Could not start transcript: $($_.Exception.Message)"
        $script:TranscriptLogPath = $null
    }

    $script:ErrorLog = [System.Collections.Generic.List[string]]::new()

    #---------------------------------------------------------------------------------------------#
    # Debug Logger Setup #
    #---------------------------------------------------------------------------------------------#
    $script:DebugLogEnabled = ($script:Options.DebugLog -eq $true)
    $script:DebugLogEntries = [System.Collections.Generic.List[string]]::new()

    #---------------------------------------------------------------------------------------------#
    # Disclaimer (HealthCheck) #
    #---------------------------------------------------------------------------------------------#
    if ($Healthcheck) {
        Section -Style TOC -ExcludeFromTOC 'DISCLAIMER' {
            Paragraph 'This report was generated using the AsBuiltReport framework. Health check indicators are provided as guidance only and should be validated by a qualified administrator before remediation. Colour coding: RED = Critical issue requiring immediate attention, YELLOW/AMBER = Warning requiring review.'
        }
        PageBreak
    }

    #---------------------------------------------------------------------------------------------#
    # Connection Section #
    #---------------------------------------------------------------------------------------------#
    foreach ($System in $Target) {

        Write-Host " "
        Write-Host "- Starting SharePoint report for tenant: $System"
        Write-TranscriptLog "Starting SharePoint report for tenant: $System" 'INFO' 'MAIN'

        #region Resolve UPN
        $ResolvedUPN = $null
        if ($Options.UserPrincipalName -and (Test-UserPrincipalName -UserPrincipalName $Options.UserPrincipalName)) {
            $ResolvedUPN = $Options.UserPrincipalName
        } elseif ($Credential -and (Test-UserPrincipalName -UserPrincipalName $Credential.UserName)) {
            $ResolvedUPN = $Credential.UserName
        } else {
            throw "No valid UserPrincipalName found. Set 'Options.UserPrincipalName' in your report config JSON (e.g. admin@$System), or pass -Credential with a UPN-format username."
        }
        Write-TranscriptLog "Using UPN: $ResolvedUPN" 'INFO' 'AUTH'
        #endregion

        #region Derive SharePoint Admin URL
        # e.g. contoso.onmicrosoft.com -> https://contoso-admin.sharepoint.com
        $TenantPrefix = ($System -split '\.')[0]
        $script:TenantAdminUrl = "https://${TenantPrefix}-admin.sharepoint.com"
        $script:TenantRootUrl  = "https://${TenantPrefix}.sharepoint.com"

        if ($Options.TenantAdminUrl -and $Options.TenantAdminUrl -ne '') {
            $script:TenantAdminUrl = $Options.TenantAdminUrl
        }
        Write-Host " - SharePoint Admin URL: $($script:TenantAdminUrl)" -ForegroundColor Cyan
        #endregion

        #region Connect
        try {
            Connect-SharePointSession -UserPrincipalName $ResolvedUPN -TenantAdminUrl $script:TenantAdminUrl
        } catch {
            Write-AbrDebugLog "Connection failed: $($_.Exception.Message)" 'ERROR' 'AUTH'
            throw "Connection failed for tenant '$System'. Error: $($_.Exception.Message)"
        }
        #endregion

        #region Retrieve Tenant Info
        # Graph data was pre-fetched in Connect-SharePointSession BEFORE PnP connected.
        # PnP.PowerShell v3 loads Graph.Core v1.25.1 which breaks Graph SDK after connecting.
        # $script:CachedOrg is populated before that happens.
        Write-Host " - Retrieving tenant information from pre-fetched cache..."
        try {
            $MgOrg = $script:CachedOrg
            if (-not $MgOrg) { throw "Organisation cache is empty -- Graph pre-fetch failed during connection." }
            $script:TenantId     = $MgOrg.id
            $script:TenantDomain = ($MgOrg.verifiedDomains | Where-Object { $_.isDefault }).name
            if (-not $script:TenantDomain) { $script:TenantDomain = $System }
            $script:TenantName   = if ($MgOrg.displayName) { $MgOrg.displayName } else { $script:TenantDomain }
            Write-Host " - Tenant: $($script:TenantName) / $($script:TenantDomain) ($($script:TenantId))" -ForegroundColor Cyan
            Write-TranscriptLog "Tenant: $($script:TenantName) / $($script:TenantDomain) ($($script:TenantId))" 'INFO' 'MAIN'
        } catch {
            $script:TenantId     = $System
            $script:TenantDomain = $System
            $script:TenantName   = $System
            Write-TranscriptLog "Unable to retrieve tenant info from cache: $($_.Exception.Message)" 'WARNING' 'MAIN'
        }
        #endregion

        #---------------------------------------------------------------------------------------------#
        # Report Sections #
        #---------------------------------------------------------------------------------------------#
        if (-not $script:TenantName)   { $script:TenantName   = $System }
        if (-not $script:TenantDomain) { $script:TenantDomain = $System }

        # Tenant Overview
        if ($InfoLevel.TenantOverview -ge 1) {
            Write-Host '- Working on Tenant Overview section.'
            Write-AbrDebugLog 'Starting TenantOverview section' 'DEBUG' 'SECTION'
            Get-AbrSPTenantOverview -TenantId $script:TenantName
        }

        # Sharing Policy + External Access
        if ($InfoLevel.SharingPolicy -ge 1 -or $InfoLevel.ExternalAccess -ge 1) {
            Write-Host '- Working on Sharing & External Access section.'
            Write-AbrDebugLog 'Starting Sharing section' 'DEBUG' 'SECTION'
            Get-AbrSPSharingSection -TenantId $script:TenantName
        }

        # Site Collections + OneDrive
        if ($InfoLevel.SiteCollections -ge 1 -or $InfoLevel.OneDrive -ge 1) {
            Write-Host '- Working on Content & Storage section.'
            Write-AbrDebugLog 'Starting Content section' 'DEBUG' 'SECTION'
            Get-AbrSPContentSection -TenantId $script:TenantName
        }

        # Compliance
        if ($InfoLevel.Compliance -ge 1) {
            Write-Host '- Working on Compliance section.'
            Write-AbrDebugLog 'Starting Compliance section' 'DEBUG' 'SECTION'
            Get-AbrSPComplianceSection -TenantId $script:TenantName
        }

        # Security Posture & Advisory (runs last -- consumes accumulated compliance check data)
        Write-AbrDebugLog 'Starting SecurityPosture section' 'DEBUG' 'SECTION'
        Get-AbrSPSecurityPostureSection -TenantId $script:TenantName

        #---------------------------------------------------------------------------------------------#
        # Output Folder #
        #---------------------------------------------------------------------------------------------#
        if ($ReportConfig.Report.OutputFolderPath -and (Test-Path $ReportConfig.Report.OutputFolderPath -ErrorAction SilentlyContinue)) {
            $script:ResolvedOutputFolder = $ReportConfig.Report.OutputFolderPath
        } elseif ($OutputFolderPath -and (Test-Path $OutputFolderPath -ErrorAction SilentlyContinue)) {
            $script:ResolvedOutputFolder = $OutputFolderPath
        } else {
            $script:ResolvedOutputFolder = Join-Path $env:USERPROFILE 'Documents'
        }
        Write-Host " - Output folder: $($script:ResolvedOutputFolder)" -ForegroundColor Cyan

        #---------------------------------------------------------------------------------------------#
        # Excel Export #
        #---------------------------------------------------------------------------------------------#
        # Consolidate compliance sheets
        if ($script:E8AllChecks -and $script:E8AllChecks.Count -gt 0) {
            $script:ExcelSheets['ACSC E8 Assessment'] = $script:E8AllChecks
        }
        if ($script:CISAllChecks -and $script:CISAllChecks.Count -gt 0) {
            $script:ExcelSheets['CIS Assessment'] = $script:CISAllChecks
        }

        if ($script:ExcelSheets -and $script:ExcelSheets.Count -gt 0) {
            Write-Host " "
            Write-Host "- Exporting data to Excel workbook..."
            Write-AbrDebugLog "Exporting $($script:ExcelSheets.Count) sheets to Excel" 'INFO' 'EXPORT'

            $ExcelOutputPath = Join-Path $script:ResolvedOutputFolder `
                "SharePoint_AsBuilt_$($script:TenantDomain)_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx"

            try {
                Export-SharePointToExcel -Sheets $script:ExcelSheets -Path $ExcelOutputPath -TenantId $script:TenantName
                Write-AbrDebugLog "Excel export complete: $ExcelOutputPath" 'SUCCESS' 'EXPORT'
            } catch {
                Write-Warning "Excel export failed: $($_.Exception.Message)"
                Write-AbrDebugLog "Excel export failed: $($_.Exception.Message)" 'ERROR' 'EXPORT'
            }
        }

        #---------------------------------------------------------------------------------------------#
        # Clean Up Connections #
        #---------------------------------------------------------------------------------------------#
        Write-Host " "
        Write-Host "- Finished report generation for tenant: $($script:TenantName)"
        Write-TranscriptLog "Report generation complete for: $($script:TenantName)" 'SUCCESS' 'MAIN'

        if ($script:Options.KeepSession -eq $true) {
            Write-Host " - KeepSession is enabled -- sessions retained." -ForegroundColor Yellow
        } else {
            Disconnect-SharePointSession
        }

        #---------------------------------------------------------------------------------------------#
        # Debug Log Export #
        #---------------------------------------------------------------------------------------------#
        if ($script:DebugLogEnabled -and $script:DebugLogEntries.Count -gt 0) {
            $script:DebugLogPath = Join-Path $script:ResolvedOutputFolder `
                "SharePoint_DebugLog_$($script:TenantDomain)_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
            try {
                $script:DebugLogEntries | Set-Content -Path $script:DebugLogPath -Encoding UTF8
                Write-Host " - Debug log saved to: $($script:DebugLogPath)" -ForegroundColor Cyan
            } catch {
                Write-Warning "Could not save debug log: $($_.Exception.Message)"
            }
        }

        if ($script:TranscriptLogPath) {
            try {
                Stop-Transcript -ErrorAction SilentlyContinue | Out-Null
                Write-Host " - Transcript saved to: $script:TranscriptLogPath" -ForegroundColor Cyan
            } catch {}
        }

    } #endregion foreach Target loop
}