M365IdentityPosture.psm1

#Requires -Version 7.0
#Requires -PSEdition Core

<#
.SYNOPSIS
    M365 Identity & Security Posture Assessment Module
.DESCRIPTION
    Comprehensive identity and access security reporting framework for Microsoft
    cloud services. Provides assessment tools for Authentication Context, Access
    Packages, Role Assignments, Conditional Access, and related security
    configurations across Microsoft 365, Azure AD/Entra ID, and hybrid scenarios.
.NOTES
    Module Name: M365IdentityPosture
    Author: Sebastian Flæng Markdanner
    Website: https://chanceofsecurity.com
    Version: 1.0.0
#>


# Module configuration
$script:ModuleRoot = $PSScriptRoot
$script:ModuleName = 'M365IdentityPosture'
$script:ModuleVersion = '1.0.0'
$script:ToolVersion = $script:ModuleVersion

# Initialize module-scoped variables
$script:graphConnected = $false
$script:CurrentTenantId = $null
$script:TenantShortName = $null
$script:LogPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "$($script:ModuleName)_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"

# Authentication data containers (module-scoped)
$script:PurviewAuthenticationData = $null
$script:SharePointAuthenticationData = $null
$script:AzureAuthenticationData = $null
$script:AllSensitivityLabels = $null

# Role name cache for performance optimization
$script:__AuthContext_RoleNameCache = @{}

# Internal logging function (define before using)
function Write-ModuleLog {
    param(
        [Parameter(Mandatory)]
        [string]$Message,
        
        [ValidateSet('Info', 'Warning', 'Error', 'Debug', 'Verbose', 'Success')]
        [string]$Level = 'Info',
        
        [switch]$NoConsole
    )
    
    $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
    $logEntry = "$timestamp [$Level] $Message"
    
    # Write to log file
    try {
        if ($script:LogPath) {
            Add-Content -Path $script:LogPath -Value $logEntry -ErrorAction SilentlyContinue
        }
    }
    catch {
        # Silently continue if log write fails
    }
    
    # Write to appropriate stream unless suppressed
    if (-not $NoConsole) {
        switch ($Level) {
            'Warning' { Write-Warning $Message }
            'Error' { Write-Error $Message }
            'Debug' { Write-Debug $Message }
            'Verbose' { Write-Verbose $Message }
            'Success' { Write-Host $Message -ForegroundColor Green }
            default { Write-Information $Message -InformationAction Continue }
        }
    }
}

# Dynamic banner display helper (internal)
function Show-ModuleBanner {
    param(
        [int]$MinWidth = 65,
        [switch]$Force,
        [switch]$NoCommands
    )

    if (-not $Force) {
        if ($env:M365IdentityPosture_QUIET -or $Host.Name -ne 'ConsoleHost') { return }
    }

    try {
        $primaryLines = @(
            "M365 Identity & Security Posture v$script:ModuleVersion",
            'Identity, Access & Security Reporting for Microsoft Cloud'
        )
        $infoLines = @(
            'Author: Sebastian Flæng Markdanner',
            'Website: https://chanceofsecurity.com'
        )

        $maxLen = @($primaryLines + $infoLines | ForEach-Object { $_.Length } | Measure-Object -Maximum).Maximum
        $innerWidth = [Math]::Max($MinWidth, $maxLen)

        $top = '╔' + ('═' * ($innerWidth + 2)) + '╗'
        $midSep = '╠' + ('═' * ($innerWidth + 2)) + '╣'
        $bottom = '╚' + ('═' * ($innerWidth + 2)) + '╝'

        Write-Host ''
        Write-Host $top -ForegroundColor Cyan
        foreach ($l in $primaryLines) {
            $padTotal = $innerWidth - $l.Length
            $leftPad = [int][Math]::Floor($padTotal / 2)
            $rightPad = $padTotal - $leftPad
            Write-Host ('║ ' + (' ' * $leftPad) + $l + (' ' * $rightPad) + ' ║') -ForegroundColor Cyan
        }
        Write-Host $midSep -ForegroundColor Cyan
        foreach ($l in $infoLines) {
            $padTotal = $innerWidth - $l.Length
            $leftPad = [int][Math]::Floor($padTotal / 2)
            $rightPad = $padTotal - $leftPad
            Write-Host ('║ ' + (' ' * $leftPad) + $l + (' ' * $rightPad) + ' ║') -ForegroundColor DarkGray
        }
        Write-Host $bottom -ForegroundColor Cyan
        Write-Host ''

        if (-not $NoCommands) {
            Write-Host 'Available Commands:' -ForegroundColor Yellow
            Write-Host ' • Invoke-AuthContextInventoryReport' -ForegroundColor Green
            Write-Host ''
            Write-Host 'For help, run: ' -NoNewline -ForegroundColor DarkGray
            Write-Host 'Get-Help Invoke-AuthContextInventoryReport -Detailed' -ForegroundColor White
            Write-Host ''
        }
    }
    catch {
        Write-Host 'M365 Reporting Framework loaded.' -ForegroundColor Cyan
    }
}

# Initialize module logging
Write-ModuleLog -Message "Module initialization started: $script:ModuleName v$script:ModuleVersion" -Level Info -NoConsole
Write-ModuleLog -Message "PowerShell Version: $($PSVersionTable.PSVersion)" -Level Info -NoConsole
Write-ModuleLog -Message "Operating System: $($PSVersionTable.OS)" -Level Info -NoConsole

# Get function definition files
Write-ModuleLog -Message 'Loading module functions...' -Level Verbose -NoConsole

# Recursively discover ALL private function files (any subfolder under Private)
$privateRoot = Join-Path $PSScriptRoot 'Private'
$Private = @()
if (Test-Path $privateRoot) {
    $Private = Get-ChildItem -Path $privateRoot -Filter '*.ps1' -Recurse -File -ErrorAction SilentlyContinue | Sort-Object FullName
    # Group for logging by relative folder
    $grouped = $Private | Group-Object { Split-Path $_.FullName -Parent }
    foreach ($g in $grouped) {
        $relative = ($g.Name -replace [Regex]::Escape($privateRoot), 'Private')
        Write-ModuleLog -Message ('Found {0} functions in {1}' -f $g.Count, $relative) -Level Verbose -NoConsole
    }
    Write-ModuleLog -Message ('Total private function files discovered recursively: {0}' -f ($Private.Count)) -Level Verbose -NoConsole
}

# Load public functions AFTER private functions
$Public = @(Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue)
Write-ModuleLog -Message "Found $($Public.Count) public functions" -Level Verbose -NoConsole

Write-ModuleLog -Message "Total functions to load: $($Public.Count + $Private.Count)" -Level Verbose -NoConsole

# Dot source the function files - PRIVATE FIRST, then PUBLIC
$functionLoadErrors = @()

# Load private functions
foreach ($import in $Private) {
    try {
        Write-ModuleLog -Message "Importing private function: $($import.Name)" -Level Verbose -NoConsole
        . $import.FullName
    }
    catch {
        $errorMsg = "Failed to import private function $($import.Name): $_"
        Write-ModuleLog -Message $errorMsg -Level Error
        $functionLoadErrors += $errorMsg
    }
}

# Load public functions (they can now access private functions)
foreach ($import in $Public) {
    try {
        Write-ModuleLog -Message "Importing public function: $($import.Name)" -Level Verbose -NoConsole
        . $import.FullName
    }
    catch {
        $errorMsg = "Failed to import public function $($import.Name): $_"
        Write-ModuleLog -Message $errorMsg -Level Error
        $functionLoadErrors += $errorMsg
    }
}

if ($functionLoadErrors.Count -gt 0) {
    throw "Module initialization failed. $($functionLoadErrors.Count) functions failed to load. Check the log at: $script:LogPath"
}

# Export only the public functions
if ($Public.Count -gt 0) {
    Export-ModuleMember -Function $Public.BaseName
    Write-ModuleLog -Message "Exported $($Public.Count) public functions: $($Public.BaseName -join ', ')" -Level Verbose -NoConsole
}

# Also explicitly export the Write-ModuleLog function for use in public functions
Export-ModuleMember -Function 'Write-ModuleLog'

# Module initialization complete
Write-ModuleLog -Message 'Module initialization completed successfully' -Level Success -NoConsole

# Display module information when loaded interactively
if ($Host.Name -eq 'ConsoleHost' -and -not $env:M365IdentityPosture_QUIET) {
    # Check if we're being called from within our own module functions
    $callStack = Get-PSCallStack
    $isInternalCall = $callStack | Where-Object { 
        $_.Command -like 'Invoke-AuthContext*' -or 
        $_.InvocationInfo.MyCommand.Module.Name -eq $script:ModuleName 
    }
    
    # Only show banner on initial import, not during function execution
    if (-not $isInternalCall) {
        Show-ModuleBanner -MinWidth 67
    }
}

# Module cleanup on removal
$OnRemoveScript = {
    Write-ModuleLog -Message 'Module removal initiated' -Level Info -NoConsole
    
    # Disconnect from services if connected
    if ($script:graphConnected) {
        try {
            Write-ModuleLog -Message 'Disconnecting from Microsoft Graph' -Level Info -NoConsole
            Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        }
        catch {
            # Silently continue
        }
    }
    
    # Clean up SharePoint connection
    if (Get-Module Microsoft.Online.SharePoint.PowerShell) {
        try {
            Write-ModuleLog -Message 'Disconnecting from SharePoint Online' -Level Info -NoConsole
            Disconnect-SPOService -ErrorAction SilentlyContinue | Out-Null
        }
        catch {
            # Silently continue
        }
    }
    
    # Clean up Exchange Online connection
    if (Get-PSSession | Where-Object { $_.ConfigurationName -eq 'Microsoft.Exchange' }) {
        try {
            Write-ModuleLog -Message 'Disconnecting from Exchange Online' -Level Info -NoConsole
            Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue | Out-Null
        }
        catch {
            # Silently continue
        }
    }
    
    # Clean up Azure connection
    $azContext = Get-AzContext -ErrorAction SilentlyContinue
    if ($azContext) {
        try {
            Write-ModuleLog -Message 'Disconnecting from Azure' -Level Info -NoConsole
            Disconnect-AzAccount -ErrorAction SilentlyContinue | Out-Null
        }
        catch {
            # Silently continue
        }
    }
    
    Write-ModuleLog -Message 'Module removal completed' -Level Info -NoConsole
    Write-Verbose "$script:ModuleName module removed and connections cleaned up"
}

$ExecutionContext.SessionState.Module.OnRemove += $OnRemoveScript

# Module is ready
Write-ModuleLog -Message 'Module ready for use' -Level Success -NoConsole
Write-Verbose "Module $script:ModuleName v$script:ModuleVersion loaded successfully"

# Test that critical functions are available
$criticalFunctions = @('Invoke-AuthContextInventoryCore', 'Invoke-Preflight', 'Invoke-GraphPhase')
$missingFunctions = @()
foreach ($func in $criticalFunctions) {
    if (-not (Get-Command $func -ErrorAction SilentlyContinue)) {
        $missingFunctions += $func
        Write-ModuleLog -Message "Critical function missing: $func" -Level Error -NoConsole
    }
}

if ($missingFunctions.Count -gt 0) {
    Write-Warning "Module loaded with missing critical functions: $($missingFunctions -join ', ')"
    Write-Warning 'Some features may not work correctly.'
}