Src/Public/Invoke-AsBuiltReport.Microsoft.EntraID.ps1
|
function Invoke-AsBuiltReport.Microsoft.EntraID { <# .SYNOPSIS PowerShell script to document the configuration of Microsoft Entra ID in Word/HTML/Text formats. .DESCRIPTION Documents the configuration of Microsoft Entra ID (formerly Azure Active Directory) in Word/HTML/Text formats using PScribo. Covers: - Tenant Overview (identity, domains, licenses, security defaults) - Users (inventory, guest users, account status, last sign-in) - MFA (registration status, per-user MFA state, users without MFA) - Authentication Methods (policy, Authenticator, FIDO2, TAP, per-user methods) - Conditional Access (policies, named locations) - Directory Roles (all assignments, Global Admins, PIM eligible) - Groups (inventory, dynamic groups) - Applications (App Registrations, Enterprise Apps, expiring secrets) - Devices (joined/registered, compliance, staleness) Excel Export: After report generation, an Excel workbook is automatically exported containing sheets for all major data sets (MFA, Auth Methods, Users, Roles, Devices, etc.) for use in audits and remediation tracking. .NOTES Version: 0.1.20 Author: Pai Wei Sing Twitter: @weising17 Github: weising26 .LINK https://github.com/AsBuiltReport/AsBuiltReport.Microsoft.EntraID .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.EntraID' #---------------------------------------------------------------------------------------------# # Dependency Module Version Check # #---------------------------------------------------------------------------------------------# $ModuleArray = @('AsBuiltReport.Core', 'Microsoft.Graph', '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 { Write-Host " - Skipping update check for $Module (PowerShellGet not available)." } } else { $Required = if ($Module -eq 'ImportExcel') { "(Optional -- required for Excel export)" } else { "" } Write-Host " - $Module module is NOT installed. $Required Run 'Install-Module -Name $Module -Force'." -ForegroundColor $(if ($Module -eq 'ImportExcel') { 'Yellow' } else { 'Red' }) } } catch { Write-PScriboMessage -IsWarning $_.Exception.Message } } #---------------------------------------------------------------------------------------------# # PRE-FLIGHT: Import Graph sub-modules BEFORE PScribo starts # #---------------------------------------------------------------------------------------------# # IMPORTANT: Graph sub-modules must be imported here, outside the PScribo document engine. # Import only the specific Graph sub-modules we need. # Importing the full Microsoft.Graph umbrella exceeds PowerShell's 4096 function limit. # Each sub-module is only imported if its key cmdlet is not already available. $script:RequiredGraphModules = @( 'Microsoft.Graph.Authentication' 'Microsoft.Graph.Identity.DirectoryManagement' 'Microsoft.Graph.Users' 'Microsoft.Graph.Groups' 'Microsoft.Graph.Applications' 'Microsoft.Graph.Identity.SignIns' 'Microsoft.Graph.Identity.Governance' ) 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)" Write-Host " [!] DETAIL -- $($_.Exception.GetType().FullName): $($_.Exception.Message)" -ForegroundColor Red } } # Log which modules actually loaded vs failed -- written to error log after path is resolved $script:PreFlightModuleStatus = $script:RequiredGraphModules | ForEach-Object { [pscustomobject]@{ Module = $_ Loaded = [bool](Get-Module -Name $_ -ErrorAction SilentlyContinue) } } #---------------------------------------------------------------------------------------------# # Import Report Configuration # #---------------------------------------------------------------------------------------------# $ReportConfig.Report.Name = 'Microsoft Entra ID As Built Report' $script:EntraLogoPath = Join-Path $PSScriptRoot 'AsBuiltReport.Microsoft.EntraID.png' if (Test-Path $script:EntraLogoPath) { $ReportConfig.Report | Add-Member -NotePropertyName 'CoverPageImage' -NotePropertyValue $script:EntraLogoPath -Force Write-Host " - Cover page logo set: $script:EntraLogoPath" -ForegroundColor Cyan } $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 -- read from Options.ComplianceFrameworks # Default to $false if not present so old config files still work $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 files in Src/Compliance/ # Populates $script:E8Definitions and $script:CISDefinitions used by Build-AbrComplianceChecks. Initialize-AbrComplianceFrameworks # Excel export sheet collector -- populated by each Get-Abr* function $script:ExcelSheets = [System.Collections.Specialized.OrderedDictionary]::new() # Compliance check accumulators -- each section appends rows with a Section column. # Consolidated into two sheets ('ACSC E8 Assessment' + 'CIS Assessment') at export time. $script:E8AllChecks = [System.Collections.ArrayList]::new() $script:CISAllChecks = [System.Collections.ArrayList]::new() #---------------------------------------------------------------------------------------------# # Transcript Log Setup # #---------------------------------------------------------------------------------------------# # Resolve output folder early so transcript can be written alongside the report $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 "EntraID_Transcript_${TranscriptTimestamp}.log" $script:ErrorLogPath = Join-Path $script:TranscriptOutputFolder "EntraID_Errors_${TranscriptTimestamp}.log" # Start PowerShell transcript - captures ALL console output including warnings 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 } # Separate structured error log - captures only errors/warnings with timestamps $script:ErrorLog = [System.Collections.Generic.List[string]]::new() function Write-EntraIDError { param([string]$Section, [string]$Message) $Entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [ERROR] [$Section] $Message" $null = $script:ErrorLog.Add($Entry) Write-Host " [ERR] $Entry" -ForegroundColor Red # Also append directly to error log file if path available if ($script:ErrorLogPath) { try { Add-Content -Path $script:ErrorLogPath -Value $Entry -ErrorAction SilentlyContinue } catch {} } } #---------------------------------------------------------------------------------------------# # Debug Logger Setup # #---------------------------------------------------------------------------------------------# # Options.DebugLog = true writes a structured debug log alongside the report. # Captures all section timings, warnings, and errors with timestamps. $script:DebugLogEnabled = ($script:Options.DebugLog -eq $true) $script:DebugLogEntries = [System.Collections.Generic.List[string]]::new() function Write-AbrDebugLog { param ( [string]$Message, [ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG', 'SUCCESS')][string]$Level = 'INFO', [string]$Section = 'GENERAL' ) $Entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] [$Level] [$Section] $Message" # Suppress Add() return value (int) to avoid leaking into PScribo document stream if ($script:DebugLogEnabled) { $null = $script:DebugLogEntries.Add($Entry) } # Suppress switch() return value - inner if($false){} returns $false which leaks as bool $null = switch ($Level) { 'ERROR' { Write-Host " [DBG-ERR] $Entry" -ForegroundColor Red } 'WARN' { Write-Host " [DBG-WRN] $Entry" -ForegroundColor Yellow } 'SUCCESS' { if ($script:DebugLogEnabled) { Write-Host " [DBG] $Entry" -ForegroundColor Green } ; $null } default { if ($script:DebugLogEnabled) { Write-Host " [DBG] $Entry" -ForegroundColor DarkGray } ; $null } } } #---------------------------------------------------------------------------------------------# # 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. Color coding: RED = Critical issue requiring immediate attention, YELLOW/AMBER = Warning requiring review.' } PageBreak } #---------------------------------------------------------------------------------------------# # Connection Section # #---------------------------------------------------------------------------------------------# foreach ($System in $Target) { Write-Host " " Write-Host "- Starting Entra ID report for tenant: $System" Write-TranscriptLog "Starting Entra ID report for tenant: $System" 'INFO' 'MAIN' Write-AbrDebugLog "Starting 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 Connect try { Connect-EntraIDSession -UserPrincipalName $ResolvedUPN } catch { Write-AbrDebugLog "Connection failed: $($_.Exception.Message)" 'ERROR' 'AUTH' throw "Connection failed for tenant '$System'. Error: $($_.Exception.Message)" } #endregion #region Retrieve Tenant Info Write-Host " - Retrieving tenant information..." try { # Use Invoke-MgGraphRequest directly to avoid Get-MgOrganization triggering # internal sub-requests that fail with MissingApiVersionParameter on SDK v2.28 $OrgResp = Invoke-MgGraphRequest -Method GET ` -Uri 'https://graph.microsoft.com/v1.0/organization?$select=id,displayName,verifiedDomains,countryLetterCode,preferredLanguage,createdDateTime' ` -ErrorAction Stop $MgOrg = if ($OrgResp.value) { $OrgResp.value[0] } else { $OrgResp } $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 identified: $($script:TenantName) / $($script:TenantDomain) ($($script:TenantId))" 'INFO' 'MAIN' Write-AbrDebugLog "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 Graph. Using '$System' as identifier." 'WARNING' 'MAIN' Write-AbrDebugLog "Unable to retrieve tenant info: $($_.Exception.Message)" 'WARN' 'MAIN' } # Detect P2 / Governance licence upfront so downstream sections can gate themselves. # Sets $script:TenantHasP2 and $script:TenantHasGovernance used by Identity Protection, # Governance, and Diagnostics sections to skip gracefully when licence is absent. $script:TenantHasP2 = $false $script:TenantHasGovernance = $false try { $LicResp = Invoke-MgGraphRequest -Method GET ` -Uri 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuPartNumber,capabilityStatus' ` -ErrorAction SilentlyContinue $SkuList = if ($LicResp.value) { $LicResp.value } else { @() } # P2 SKU identifiers (AAD P2 standalone, E5, M365 E5, EMS E5, A5 Education) $P2Skus = @('AAD_PREMIUM_P2', 'ENTERPRISEPREMIUM', 'ENTERPRISEPREMIUM_NOPSTNCONF', 'SPE_E5', 'M365EDU_A5_FACULTY', 'M365EDU_A5_STUDENT', 'EMSPREMIUM', 'EMS_EDU_FACULTY', 'IDENTITY_THREAT_PROTECTION') $GovSkus = @('AAD_PREMIUM_P2', 'ENTERPRISEPREMIUM', 'SPE_E5', 'IDENTITY_GOVERNANCE', 'AAD_IDENTITY_GOVERNANCE') $ActiveSkus = $SkuList | Where-Object { $_.capabilityStatus -eq 'Enabled' } $script:TenantHasP2 = ($ActiveSkus | Where-Object { $P2Skus -contains $_.skuPartNumber }).Count -gt 0 $script:TenantHasGovernance = ($ActiveSkus | Where-Object { $GovSkus -contains $_.skuPartNumber }).Count -gt 0 if ($script:TenantHasP2) { Write-Host ' - Licence: Entra ID P2 detected -- Identity Protection section enabled' -ForegroundColor Cyan } if ($script:TenantHasGovernance) { Write-Host ' - Licence: Identity Governance detected -- Governance section enabled' -ForegroundColor Cyan } if (-not $script:TenantHasP2) { Write-Host ' - Licence: No Entra ID P2 -- Identity Protection section will be skipped' -ForegroundColor Yellow } if (-not $script:TenantHasGovernance) { Write-Host ' - Licence: No Governance licence -- Governance section will be skipped' -ForegroundColor Yellow } } catch { Write-AbrDebugLog "Licence check failed: $($_.Exception.Message)" 'WARN' 'MAIN' } #endregion #---------------------------------------------------------------------------------------------# # Report Sections # #---------------------------------------------------------------------------------------------# # All content must be wrapped in a Heading1 section -- PScribo requires a top-level # Section container; Heading2/Heading3 blocks placed directly in the document root # are treated as unsupported objects and silently dropped, producing an empty document. # Safety guard: ensure TenantName/TenantDomain are never null/empty 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-AbrEntraIDTenantOverview -TenantId $script:TenantName } # Tenant Security Settings (user permissions, B2B, password protection) if ($InfoLevel.TenantSettings -ge 1) { Write-Host '- Working on Tenant Security Settings section.' Write-AbrDebugLog 'Starting TenantSettings section' 'DEBUG' 'SECTION' Get-AbrEntraIDTenantSettingsSection -TenantId $script:TenantName } # Identity (Users + Groups) if ($InfoLevel.Users -ge 1 -or $InfoLevel.Groups -ge 1) { Write-Host '- Working on Identity section.' Write-AbrDebugLog 'Starting Identity section' 'DEBUG' 'SECTION' Get-AbrEntraIDIdentitySection -TenantId $script:TenantName } # Security (MFA + Auth Methods + Conditional Access + Roles) if ($InfoLevel.MFA -ge 1 -or $InfoLevel.AuthenticationMethods -ge 1 -or $InfoLevel.ConditionalAccess -ge 1 -or $InfoLevel.Roles -ge 1) { Write-Host '- Working on Security section.' Write-AbrDebugLog 'Starting Security section' 'DEBUG' 'SECTION' Get-AbrEntraIDSecuritySection -TenantId $script:TenantName } # SSPR # Identity Protection (risk users, risk policies) -- requires Entra ID P2 # Security Posture & Advisory if ($InfoLevel.MFA -ge 1 -or $InfoLevel.ConditionalAccess -ge 1) { Write-Host '- Working on Security Posture & Advisory section.' Write-AbrDebugLog 'Starting SecurityPosture section' 'DEBUG' 'SECTION' Get-AbrEntraIDSecurityPostureSection -TenantId $script:TenantName } # Applications if ($InfoLevel.Applications -ge 1 -or $InfoLevel.ServicePrincipals -ge 1) { Write-Host '- Working on Applications section.' Write-AbrDebugLog 'Starting Applications section' 'DEBUG' 'SECTION' Get-AbrEntraIDApplicationsSection -TenantId $script:TenantName } # Devices if ($InfoLevel.Devices -ge 1) { Write-Host '- Working on Devices section.' Write-AbrDebugLog 'Starting Devices section' 'DEBUG' 'SECTION' Get-AbrEntraIDDevicesSection -TenantId $script:TenantName } # Identity Governance (Access Reviews, Entitlement Mgmt, Terms of Use) -- requires P2/Governance if ($InfoLevel.Governance -ge 1) { if ($script:TenantHasGovernance -or $script:TenantHasGovernance -eq $null) { Write-Host '- Working on Identity Governance section.' Write-AbrDebugLog 'Starting Governance section' 'DEBUG' 'SECTION' Get-AbrEntraIDGovernanceSection -TenantId $script:TenantName } else { Write-Host '- Skipping Identity Governance section (Entra ID P2 / Governance licence not detected).' -ForegroundColor Yellow } } # Diagnostic Settings & Audit Logging if ($InfoLevel.Diagnostics -ge 1) { Write-Host '- Working on Diagnostic Settings section.' Write-AbrDebugLog 'Starting Diagnostics section' 'DEBUG' 'SECTION' Get-AbrEntraIDDiagnosticsSection -TenantId $script:TenantName } #---------------------------------------------------------------------------------------------# # PScribo Document Structure Audit (always runs) # #---------------------------------------------------------------------------------------------# # Audits the live document tree for empty sections/tables that crash the Word exporter. # Runs before Excel export so the error log path is not yet set -- output goes to host only. # The full audit with log file output runs again after the output folder is resolved below. Write-Host "" Write-Host "- Running PScribo document structure audit..." -ForegroundColor Yellow # PScribo exposes the document via $Document in the scriptblock scope try { if (Get-Command Invoke-PScriboDocumentAudit -ErrorAction SilentlyContinue) { $script:AuditProblems = Invoke-PScriboDocumentAudit -Document $Document -LogPath $null } } catch { Write-Host " [AUDIT] Audit skipped: $($_.Exception.Message)" -ForegroundColor DarkYellow } # Resolved here so both Excel and the debug log writer always have a valid path, # regardless of which one runs first or whether Excel sheets were collected. 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 Write-AbrDebugLog "Output folder resolved: $($script:ResolvedOutputFolder)" 'INFO' 'MAIN' # Re-run audit now that error log path is available -- writes problems to file try { if ((Get-Command Invoke-PScriboDocumentAudit -ErrorAction SilentlyContinue) -and $script:ErrorLogPath) { $null = Invoke-PScriboDocumentAudit -Document $Document -LogPath $script:ErrorLogPath } } catch { } # Dump pre-flight module load status now that error log path is available if ($script:PreFlightModuleStatus -and $script:ErrorLogPath) { $pfLines = @("", "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [DIAG] [PRE-FLIGHT] Graph module load results:") foreach ($m in $script:PreFlightModuleStatus) { $status = if ($m.Loaded) { '[OK] ' } else { '[FAILED]' } $pfLines += " $status $($m.Module)" } $pfLines += "" Add-Content -Path $script:ErrorLogPath -Value ($pfLines -join "`n") -ErrorAction SilentlyContinue foreach ($line in $pfLines) { Write-Host $line -ForegroundColor $(if ($line -like '*FAILED*') { 'Red' } else { 'DarkCyan' }) } } if ($script:DebugLogEnabled) { $script:DebugLogPath = Join-Path $script:ResolvedOutputFolder ` "EntraID_DebugLog_$($script:TenantDomain)_$(Get-Date -Format 'yyyyMMdd_HHmmss').log" Write-Host " - Debug log path: $($script:DebugLogPath)" -ForegroundColor Cyan } #---------------------------------------------------------------------------------------------# # Excel Export # #---------------------------------------------------------------------------------------------# # Consolidate all E8 and CIS compliance check rows into two summary sheets. # Each row has a Section column so the reader can filter by area. 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 ` "EntraID_AsBuilt_$($script:TenantDomain)_$(Get-Date -Format 'yyyyMMdd_HHmmss').xlsx" try { Export-EntraIDToExcel -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' Write-TranscriptLog "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' Write-AbrDebugLog "Report generation complete for: $($script:TenantName)" 'SUCCESS' 'MAIN' # Save structured error log if there were any errors if ($script:ErrorLog -and $script:ErrorLog.Count -gt 0) { Write-Host " - $($script:ErrorLog.Count) error(s) logged to: $script:ErrorLogPath" -ForegroundColor Yellow } # Options.KeepSession = true skips disconnect -- useful during testing if ($script:Options.KeepSession -eq $true) { Write-Host " - KeepSession is enabled -- Microsoft Graph session retained." -ForegroundColor Yellow Write-AbrDebugLog "KeepSession=true -- skipping disconnect" 'INFO' 'AUTH' } else { Disconnect-EntraIDSession } #---------------------------------------------------------------------------------------------# # Debug Log Export # #---------------------------------------------------------------------------------------------# if ($script:DebugLogEnabled -and $script:DebugLogEntries.Count -gt 0) { 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)" } } # Stop transcript if ($script:TranscriptLogPath) { try { Stop-Transcript -ErrorAction SilentlyContinue | Out-Null Write-Host " - Transcript saved to: $script:TranscriptLogPath" -ForegroundColor Cyan } catch {} } } #endregion foreach Target loop } |