Public/Invoke-PSUAzureAppRegAudit.ps1

<#
.SYNOPSIS
    Performs a comprehensive audit and cleanup assessment of all Azure App Registrations in a tenant.
 
.DESCRIPTION
    Collects 13 signals per app across 9,000+ App Registrations, calculates a
    Deletion Safety Score (0-100), assigns each app to one of four cleanup buckets,
    and produces actionable output files for the IT team.
 
    Architecture: Collect -> Score -> Bucket -> Output -> Act
 
    The four cleanup buckets are:
    - Bucket 1 (SafeToDisable) : Score 80-100 - safe to disable now, delete in 30 days
    - Bucket 2 (NeedsInvestigation) : Score 50-79 - send to app owner for review
    - Bucket 3 (LikelyActive) : Score 20-49 - do not touch, gather more evidence
    - Bucket 4 (BusinessCritical) : Score 0-19 - never touch without change request
 
    The script uses:
    - Microsoft Graph PowerShell SDK v2+ for Entra ID data
    - Az.Resources module for Azure RBAC coverage
    - Bulk-load caching architecture for performance (O(1) hashtable lookups)
    - signInActivity property for ALL-TIME sign-in dates (no retention limit)
 
    No automatic deletions occur. All actions require human review.
 
.PARAMETER TenantId
    (Optional) The Entra ID tenant ID. If not supplied, reads from Get-MgContext.
 
.PARAMETER OutputDirectory
    (Optional) Path where output CSV files are written.
    Default is $PSScriptRoot.
 
.PARAMETER LogDirectory
    (Optional) Path where the audit log file is written.
    Default is $PSScriptRoot.
 
.PARAMETER SkipAzureRBAC
    (Optional) Skip Azure RBAC data collection. Use this if the Az.Resources module
    is not available or Azure subscription access is not granted.
 
.PARAMETER SkipSignInLogs
    (Optional) Skip per-app sign-in log queries. This makes the script much faster
    (30 min vs 3-4 hours) but reduces signal coverage. The signInActivity property
    on Service Principals still provides ALL-TIME sign-in dates.
 
.PARAMETER ThrottleDelayMs
    (Optional) Base delay in milliseconds between per-app Graph calls.
    Default is 200ms.
 
.PARAMETER DryRunLimit
    (Optional) If greater than 0, process only this many apps. Use for testing.
    Default is 0 (process all apps).
 
.PARAMETER VerboseMode
    (Optional) Enable verbose logging to the log file.
 
.EXAMPLE
    Invoke-PSUAzureAppRegAudit -OutputDirectory "C:\AuditOutput" -SkipSignInLogs
 
    Runs a fast audit (no per-app sign-in log queries) and saves output to C:\AuditOutput.
 
.EXAMPLE
    Invoke-PSUAzureAppRegAudit -DryRunLimit 50 -VerboseMode
 
    Processes only the first 50 apps with verbose logging enabled.
 
.EXAMPLE
    Invoke-PSUAzureAppRegAudit -TenantId "abc-123" -SkipAzureRBAC
 
    Runs a full audit without Azure RBAC data collection.
 
.OUTPUTS
    [System.Collections.Generic.List[PSCustomObject]]
    Returns the MasterReport list containing all app audit records.
 
    Output files:
    - MasterAudit_Full_<TenantId>_<Date>.csv
    - Bucket1_SafeToDisable_<TenantId>_<Date>.csv
    - Bucket2_NeedsInvestigation_<TenantId>_<Date>.csv
    - Bucket3_LikelyActive_<TenantId>_<Date>.csv
    - Bucket4_BusinessCritical_<TenantId>_<Date>.csv
    - ManagedIdentity_Audit_<TenantId>_<Date>.csv
    - OwnerNotifications\Owner_<UPN>_<Date>.csv (one per owner)
    - OwnerNotifications\NoOwner_Escalation_<Date>.csv
    - AuditLog_<TenantId>_<DateTime>.log
 
.NOTES
    Author: Lakshmanachari Panuganti
    Created: 7th March 2026
    Last Modified: 7th March 2026
    Version: 1.0
 
    IMPORTANT:
    - Sign-in log retention: 7-30 days depending on Entra ID license tier.
      signInActivity on Service Principals provides ALL-TIME sign-in dates.
    - Managed Identity ARM resource link requires Azure Resource Graph (future enhancement).
    - Microsoft 1st-party apps are auto-excluded from cleanup buckets 1-3.
    - No automatic deletions occur. All actions require human review.
    - Estimated runtime: ~30min with -SkipSignInLogs / ~3-4hrs without.
 
    PREREQUISITES:
    - PowerShell 7.2+
    - Microsoft.Graph.Authentication 2.0.0+
    - Microsoft.Graph.Applications 2.0.0+
    - Microsoft.Graph.Reports 2.0.0+
    - Microsoft.Graph.Identity.Governance 2.0.0+
    - Microsoft.Graph.Identity.DirectoryManagement 2.0.0+
    - Az.Resources 6.0.0+ (unless -SkipAzureRBAC)
 
.LINK
    https://github.com/lakshmanachari-panuganti/OMG.PSUtilities/tree/main/OMG.PSUtilities.AzureCore
    https://www.linkedin.com/in/lakshmanachari-panuganti/
    https://www.powershellgallery.com/packages/OMG.PSUtilities.AzureCore
#>

function Invoke-PSUAzureAppRegAudit {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [string]$OutputDirectory = $PSScriptRoot,

        [Parameter()]
        [string]$LogDirectory = $PSScriptRoot,

        [Parameter()]
        [switch]$SkipAzureRBAC,

        [Parameter()]
        [switch]$SkipSignInLogs,

        [Parameter()]
        [int]$ThrottleDelayMs = 200,

        [Parameter()]
        [int]$DryRunLimit = 0,

        [Parameter()]
        [switch]$VerboseMode
    )

    begin {
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] Parameters:"
        foreach ($param in $PSBoundParameters.GetEnumerator()) {
            Write-Verbose " $($param.Key) = $($param.Value)"
        }

        #region Constants and Lookup Hashtables
        $DateStamp = Get-Date -Format "yyyyMMdd"
        $script:LogFile = Join-Path $LogDirectory "AuditLog_PENDING_$( Get-Date -Format 'yyyyMMdd_HHmmss' ).log"
        $ScriptStartTime = Get-Date

        # Microsoft 1st-party tenant ID
        $MicrosoftTenantId = "f8cdef31-a31e-4b4a-93e4-5f571e91255a"

        # Broad Graph Permissions (GUID -> permission name)
        $BroadPermissions = @{
            "df021288-bdef-4463-88db-98f22de89214" = "User.ReadWrite.All"
            "62a82d76-70ea-41e2-9197-370581804d09" = "Group.ReadWrite.All"
            "19dbc75e-c2e2-444c-a770-ec69d8559fc7" = "Directory.ReadWrite.All"
            "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" = "RoleManagement.ReadWrite.Directory"
            "e2a3a72e-5f79-4c64-b1b1-878b674786c9" = "Mail.ReadWrite"
            "75359482-378d-4052-8f01-80520e7db3cd" = "Files.ReadWrite.All"
            "dc50a0fb-09a3-484d-be87-e023b12c6440" = "SecurityEvents.ReadWrite.All"
            "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9" = "Application.ReadWrite.All"
            "dbaae8cf-10b5-4b86-a4a1-f871c94c6571" = "GroupMember.ReadWrite.All"
            "9492366f-7969-46a4-8d15-ed1a20078fff" = "Sites.ReadWrite.All"
            "6931bccd-447a-43d1-b442-00a195474b6c" = "MailboxSettings.ReadWrite"
            "ef54d2bf-783f-4e0f-bca1-3210c0444d99" = "Calendars.ReadWrite"
            "741f803b-c850-494e-b5df-cde7c675a1ca" = "User.ReadWrite.All (Delegated)"
        }

        $PrivilegedEntraRoles = @(
            "Global Administrator", "Privileged Role Administrator",
            "Application Administrator", "Cloud Application Administrator",
            "Exchange Administrator", "SharePoint Administrator"
        )

        $PrivilegedAzureRoles = @("Owner", "Contributor", "User Access Administrator")

        $graphResourceId = "00000003-0000-0000-c000-000000000000"

        $OwnerNotificationColumns = @(
            "AppName", "AppId", "DeletionSafetyScore", "BucketLabel", "RecommendedAction",
            "LastUsedDate", "UsageConfidence", "UsageLocationSummary", "DeletionBlastRadius",
            "HasExpiredSecret", "OldestSecretAgeDays", "AuthMethod", "CreatedDate"
        )
        #endregion

        #region Write-Log function
        function Write-Log {
            param(
                [ValidateSet("INFO", "WARN", "ERROR", "DEBUG")]
                [string]$Level = "INFO",
                [string]$Signal = "General",
                [string]$Message
            )
            $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            $entry = "[$ts] [$Level] [$Signal] $Message"
            Add-Content -Path $script:LogFile -Value $entry
            switch ($Level) {
                "ERROR" { Write-Warning $Message }
                "WARN" { Write-Warning $Message }
                "DEBUG" { if ($VerboseMode) { Write-Verbose $Message } }
                default { Write-Host $entry }
            }
        }
        #endregion

        # LogFunction scriptblock for passing to Invoke-PSUGraphWithRetry
        $LogFunc = {
            param($Level, $Signal, $Message)
            Write-Log -Level $Level -Signal $Signal -Message $Message
        }
    }

    process {
        if (-not $PSCmdlet.ShouldProcess("Tenant '$TenantId'", 'Run Azure App Registration Audit')) {
            return
        }

        try {
            #region Pre-flight Validation
            Write-Log "INFO" "PreFlight" "Starting pre-flight validation..."

            # Check 1: Graph context
            $mgContext = Get-MgContext
            if ($null -eq $mgContext) {
                throw "ERROR: No Graph context. Run Connect-MgGraph first."
            }

            # Check 2: Required scopes
            $requiredScopes = @(
                "Application.Read.All", "Directory.Read.All", "AuditLog.Read.All",
                "Policy.Read.All"
            )
            $currentScopes = $mgContext.Scopes
            $missingScopes = $requiredScopes | Where-Object { $_ -notin $currentScopes }
            if ($missingScopes.Count -gt 0) {
                throw "ERROR: Missing Graph scopes: $($missingScopes -join ', '). Re-run Connect-MgGraph with required scopes."
            }

            # Check 2b: Validate Graph token with a lightweight API call
            try {
                $null = Get-MgOrganization -ErrorAction Stop | Select-Object -First 1
                Write-Log "INFO" "PreFlight" "Graph token validated (Get-MgOrganization succeeded)"
            } catch {
                throw "ERROR: Graph token is invalid or expired. Re-authenticate with: Connect-MgGraph -Scopes @('Application.Read.All','Directory.Read.All','AuditLog.Read.All','Policy.Read.All'). Inner error: $($_.Exception.Message)"
            }

            # Check 3: Azure context (if RBAC not skipped)
            if (-not $SkipAzureRBAC) {
                $azContext = Get-AzContext
                if ($null -eq $azContext) {
                    throw "ERROR: No Azure context. Run Connect-AzAccount or use -SkipAzureRBAC."
                }
            }

            # Check 4: Resolve TenantId
            if ([string]::IsNullOrWhiteSpace($TenantId)) {
                $TenantId = $mgContext.TenantId
            }

            # Update log filename with TenantId
            $newLogFile = Join-Path $LogDirectory "AuditLog_${TenantId}_$( Get-Date -Format 'yyyyMMdd_HHmmss' ).log"
            if (Test-Path $script:LogFile) {
                Rename-Item -Path $script:LogFile -NewName (Split-Path $newLogFile -Leaf) -ErrorAction SilentlyContinue
            }
            $script:LogFile = $newLogFile

            # Check 5: Cache Azure subscriptions
            $AzSubscriptions = @()
            if (-not $SkipAzureRBAC) {
                $AzSubscriptions = @(Get-AzSubscription -TenantId $TenantId)
                Write-Log "INFO" "PreFlight" "Cached $($AzSubscriptions.Count) Azure subscriptions"
            }

            # Create output directories
            $ownerNotifDir = Join-Path $OutputDirectory "OwnerNotifications"
            if (-not (Test-Path $OutputDirectory)) { New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null }
            if (-not (Test-Path $ownerNotifDir)) { New-Item -Path $ownerNotifDir   -ItemType Directory -Force | Out-Null }

            $authIdentity = $mgContext.Account ?? $mgContext.AppName ?? "Unknown"
            Write-Log "INFO" "PreFlight" "Authenticated: $authIdentity | Tenant: $TenantId | Subscriptions: $($AzSubscriptions.Count)"
            Write-Log "INFO" "PreFlight" "Pre-flight validation PASSED"
            #endregion

            #region Phase 1: Bulk Pre-Load

            # -- Bulk Load 1: All App Registrations (with owners + federated creds) --
            Write-Log "INFO" "Bulk" "Loading all App Registrations with owners and federated credentials..."
            $AllApps = Get-MgApplication -All `
                -ExpandProperty "owners,federatedIdentityCredentials" `
                -Property @(
                "id", "appId", "displayName", "createdDateTime",
                "signInAudience", "publisherDomain", "tags",
                "passwordCredentials", "keyCredentials", "requiredResourceAccess",
                "web", "spa", "publicClient",
                "api", "info", "notes",
                "verifiedPublisher"
            )

            if ($null -eq $AllApps -or $AllApps.Count -eq 0) {
                Write-Log "ERROR" "Bulk" "ZERO App Registrations returned. Check Application.Read.All scope."
                throw "Bulk load returned no applications. Verify permissions and try again."
            }

            $AppCache = @{}
            foreach ($app in $AllApps) { $AppCache[$app.AppId] = $app }
            Write-Log "INFO" "Bulk" "Loaded $($AllApps.Count) App Registrations (with owners + federated creds)"

            # -- Bulk Load 2: All Service Principals (with sign-in activity) --
            Write-Log "INFO" "Bulk" "Loading all Service Principals with signInActivity..."
            $AllSPs = Get-MgServicePrincipal -All -Property @(
                "id", "appId", "displayName", "accountEnabled",
                "appOwnerOrganizationId", "servicePrincipalType", "createdDateTime",
                "signInActivity", "tags", "homepage", "replyUrls",
                "alternativeNames"
            )

            if ($null -eq $AllSPs -or $AllSPs.Count -eq 0) {
                Write-Log "ERROR" "Bulk" "ZERO Service Principals returned. Check Directory.Read.All scope."
                throw "Bulk load returned no service principals. Verify permissions and try again."
            }

            $SpCache = @{}
            $MiCache = @{}
            $MicrosoftAppIds = [System.Collections.Generic.HashSet[string]]::new()

            foreach ($sp in $AllSPs) {
                $SpCache[$sp.AppId] = $sp
                if ($sp.ServicePrincipalType -eq "ManagedIdentity") {
                    $MiCache[$sp.Id] = $sp
                }
                if ($sp.AppOwnerOrganizationId -eq $MicrosoftTenantId) {
                    [void]$MicrosoftAppIds.Add($sp.AppId)
                }
            }
            Write-Log "INFO" "Bulk" "Loaded $($AllSPs.Count) Service Principals ($($MiCache.Count) MIs, $($MicrosoftAppIds.Count) Microsoft 1st-party)"

            # -- Bulk Load 3: All OAuth2 Permission Grants --
            Write-Log "INFO" "Bulk" "Loading OAuth2 Permission Grants..."
            $AllOAuthGrants = Get-MgOauth2PermissionGrant -All -Property @(
                "id", "clientId", "resourceId", "scope", "consentType", "principalId"
            )
            $OAuthGrantCache = @{}
            foreach ($grant in $AllOAuthGrants) {
                if (-not $OAuthGrantCache.ContainsKey($grant.ClientId)) {
                    $OAuthGrantCache[$grant.ClientId] = [System.Collections.Generic.List[object]]::new()
                }
                $OAuthGrantCache[$grant.ClientId].Add($grant)
            }
            Write-Log "INFO" "Bulk" "Loaded $(if ($AllOAuthGrants) { $AllOAuthGrants.Count } else { 0 }) OAuth2 Permission Grants"

            # -- Bulk Load 4: All App Role Assignments (Outbound) --
            Write-Log "INFO" "Bulk" "Loading outbound App Role Assignments (iterating SPs)..."
            $OutboundRoleCache = @{}
            $outboundTotal = 0

            foreach ($sp in $AllSPs) {
                if ($sp.ServicePrincipalType -eq "ManagedIdentity") { continue }
                if ($MicrosoftAppIds.Contains($sp.AppId)) { continue }

                $assignments = Invoke-PSUGraphWithRetry -OperationName "OutboundRoles-$($sp.Id)" -ScriptBlock {
                    Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $sp.Id -All
                } -LogFunction $LogFunc

                if ($null -ne $assignments -and $assignments.Count -gt 0) {
                    $OutboundRoleCache[$sp.Id] = [System.Collections.Generic.List[object]]::new()
                    foreach ($assignment in $assignments) {
                        $OutboundRoleCache[$sp.Id].Add($assignment)
                    }
                    $outboundTotal += $assignments.Count
                }

                if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
            }
            Write-Log "INFO" "Bulk" "Loaded $outboundTotal outbound App Role Assignments across $($OutboundRoleCache.Count) SPs"

            # -- Bulk Load 5: All Inbound App Role Assignments (Consumers) --
            Write-Log "INFO" "Bulk" "Loading inbound App Role Assignments (iterating SPs)..."
            $InboundRoleCache = @{}
            $inboundTotal = 0

            foreach ($sp in $AllSPs) {
                if ($sp.ServicePrincipalType -eq "ManagedIdentity") { continue }
                if ($MicrosoftAppIds.Contains($sp.AppId)) { continue }

                $assignments = Invoke-PSUGraphWithRetry -OperationName "InboundRoles-$($sp.Id)" -ScriptBlock {
                    Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $sp.Id -All
                } -LogFunction $LogFunc

                if ($null -ne $assignments -and $assignments.Count -gt 0) {
                    $InboundRoleCache[$sp.Id] = [System.Collections.Generic.List[object]]::new()
                    foreach ($assignment in $assignments) {
                        $InboundRoleCache[$sp.Id].Add($assignment)
                    }
                    $inboundTotal += $assignments.Count
                }

                if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
            }
            Write-Log "INFO" "Bulk" "Loaded $inboundTotal inbound App Role Assignments across $($InboundRoleCache.Count) SPs"

            # -- Bulk Load 6: All Directory Role Assignments --
            Write-Log "INFO" "Bulk" "Loading Directory Role Definitions..."
            $AllRoleDefinitions = Get-MgRoleManagementDirectoryRoleDefinition -All -Property @(
                "id", "displayName", "isBuiltIn"
            )
            $RoleDefCache = @{}
            foreach ($rd in $AllRoleDefinitions) { $RoleDefCache[$rd.Id] = $rd }
            Write-Log "INFO" "Bulk" "Loaded $($AllRoleDefinitions.Count) Directory Role Definitions"

            Write-Log "INFO" "Bulk" "Loading Directory Role Assignments..."
            $AllDirRoleAssignments = Get-MgRoleManagementDirectoryRoleAssignment -All `
                -Property "id,principalId,roleDefinitionId,directoryScopeId"

            $DirRoleCache = @{}
            foreach ($ra in $AllDirRoleAssignments) {
                $ra | Add-Member -NotePropertyName "RoleDefinition" -NotePropertyValue $RoleDefCache[$ra.RoleDefinitionId] -Force
                if (-not $DirRoleCache.ContainsKey($ra.PrincipalId)) {
                    $DirRoleCache[$ra.PrincipalId] = [System.Collections.Generic.List[object]]::new()
                }
                $DirRoleCache[$ra.PrincipalId].Add($ra)
            }
            Write-Log "INFO" "Bulk" "Loaded $($AllDirRoleAssignments.Count) Directory Role Assignments"

            # -- Bulk Load 7: Azure RBAC Assignments --
            $AzureRbacCache = @{}
            if (-not $SkipAzureRBAC) {
                Write-Log "INFO" "Bulk" "Loading Azure RBAC Assignments across $($AzSubscriptions.Count) subscriptions..."
                foreach ($sub in $AzSubscriptions) {
                    try {
                        Set-AzContext -SubscriptionId $sub.Id -ErrorAction Stop | Out-Null
                        $subAssignments = Get-AzRoleAssignment -ErrorAction Stop
                        foreach ($ra in $subAssignments) {
                            if ([string]::IsNullOrEmpty($ra.ObjectId)) { continue }
                            if (-not $AzureRbacCache.ContainsKey($ra.ObjectId)) {
                                $AzureRbacCache[$ra.ObjectId] = [System.Collections.Generic.List[object]]::new()
                            }
                            $AzureRbacCache[$ra.ObjectId].Add($ra)
                        }
                    } catch {
                        Write-Log "WARN" "AzureRBAC" "Could not load RBAC from sub $($sub.Id): $($_.Exception.Message)"
                    }
                }
                Write-Log "INFO" "Bulk" "Loaded Azure RBAC assignments across $($AzSubscriptions.Count) subscriptions"
            }

            # -- Bulk Load 8: Conditional Access Policies --
            Write-Log "INFO" "Bulk" "Loading Conditional Access Policies..."
            $AllCAPolicies = Get-MgIdentityConditionalAccessPolicy -All -Property @(
                "id", "displayName", "state", "conditions"
            )
            $CaProtectedAppIds = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($policy in $AllCAPolicies) {
                if ($policy.State -in @("enabled", "Enabled", "enabledForReportingButNotEnforced")) {
                    foreach ($appId in $policy.Conditions.Applications.IncludeApplications) {
                        [void]$CaProtectedAppIds.Add($appId)
                    }
                }
            }
            Write-Log "INFO" "Bulk" "Loaded $($AllCAPolicies.Count) CA policies. Protected apps: $($CaProtectedAppIds.Count)"

            #endregion

            #region Phase 2: Per-App Signal Collection

            $MasterReport = [System.Collections.Generic.List[object]]::new()
            $appsToProcess = if ($DryRunLimit -gt 0) { $AllApps | Select-Object -First $DryRunLimit } else { $AllApps }
            $totalApps = @($appsToProcess).Count
            $currentApp = 0

            if (-not $SkipSignInLogs -and $totalApps -gt 100) {
                Write-Log "WARN" "SignIn" "Processing $totalApps apps WITH sign-in log queries. Estimated time: 2-4 hours."
            }

            foreach ($app in $appsToProcess) {
                $currentApp++
                Write-Progress -Activity "Auditing App Registrations" -Status "$currentApp / $totalApps - $($app.DisplayName)" -PercentComplete (($currentApp / $totalApps) * 100)

                #region Signal A: App Registration Base Properties
                $ObjectId = $app.Id
                $AppId = $app.AppId
                $DisplayName = $app.DisplayName
                $CreatedDateTime = $app.CreatedDateTime
                $SignInAudience = $app.SignInAudience
                $PublisherDomain = $app.PublisherDomain
                $Tags = ($app.Tags -join ";")

                $VerifiedPublisher = $app.VerifiedPublisher.DisplayName ?? "NotVerified"
                $IsVerifiedPublisher = ($null -ne $app.VerifiedPublisher -and
                    -not [string]::IsNullOrWhiteSpace($app.VerifiedPublisher.DisplayName))
                $IsMicrosoftApp = $MicrosoftAppIds.Contains($AppId)
                $AppAgeDays = [int]((Get-Date) - $CreatedDateTime).TotalDays

                $WebRedirectUris = ($app.Web.RedirectUris -join ";")
                $SpaRedirectUris = ($app.Spa.RedirectUris -join ";")
                $PublicClientUris = ($app.PublicClient.RedirectUris -join ";")
                $AllRedirectUris = @($app.Web.RedirectUris + $app.Spa.RedirectUris + $app.PublicClient.RedirectUris) | Where-Object { $_ }
                $HasLocalhostUri = ($AllRedirectUris | Where-Object { $_ -match "localhost" }).Count -gt 0
                $HasHttpUri = ($AllRedirectUris | Where-Object { $_ -match "^http://" }).Count -gt 0
                $HasNgrokUri = ($AllRedirectUris | Where-Object { $_ -match "ngrok" }).Count -gt 0
                $RedirectUriCount = $AllRedirectUris.Count

                $IsPublicClient = ($null -ne $app.PublicClient -and $app.PublicClient.RedirectUris.Count -gt 0)

                $ExposedScopeCount = $app.Api.Oauth2PermissionScopes.Count
                $ExposedRoleCount = $app.Api.AppRoles.Count
                $IsApiProvider = ($ExposedScopeCount -gt 0 -or $ExposedRoleCount -gt 0)
                $ExposedPermissions = @(
                    ($app.Api.Oauth2PermissionScopes | ForEach-Object { $_.Value })
                    ($app.Api.AppRoles | ForEach-Object { $_.Value })
                ) -join ";"

                $DeclaredPermissionCount = ($app.RequiredResourceAccess | ForEach-Object { $_.ResourceAccess.Count } | Measure-Object -Sum).Sum
                #endregion

                #region Signal B: Service Principal + ALL-TIME Sign-in Activity
                $sp = $SpCache[$AppId]

                if ($null -eq $sp) {
                    $ServicePrincipalId = "NoSP"
                    $IsEnabled = "N/A"
                    $NoServicePrincipal = $true
                    $ServicePrincipalType = "N/A"
                    $SPLastSignInDate = $null
                    $SPLastDaemonSignInDate = $null
                } else {
                    $ServicePrincipalId = $sp.Id
                    $IsEnabled = $sp.AccountEnabled
                    $NoServicePrincipal = $false
                    $ServicePrincipalType = $sp.ServicePrincipalType
                    $SPLastSignInDate = $sp.SignInActivity.LastSignInDateTime
                    $SPLastDaemonSignInDate = $sp.SignInActivity.LastNonInteractiveSignInDateTime
                }
                #endregion

                #region Signal C: Owners (from $expand cache)
                $owners = $app.Owners
                $OwnerUPNs = if ($owners.Count -gt 0) {
                    ($owners | ForEach-Object { $_.AdditionalProperties.userPrincipalName ?? $_.AdditionalProperties.displayName ?? $_.Id }) -join ";"
                } else { "NoOwner" }
                $OwnerCount = $owners.Count
                $HasOwner = $owners.Count -gt 0
                #endregion

                #region Signal D: Federated Identity Credentials (from $expand cache)
                $fedCreds = $app.FederatedIdentityCredentials
                $FederatedCredentialCount = if ($null -ne $fedCreds) { $fedCreds.Count } else { 0 }
                $FederatedCredentials = if ($FederatedCredentialCount -gt 0) { ($fedCreds | ForEach-Object { $_.Name }) -join ";" } else { "None" }
                $FederatedIssuers = if ($FederatedCredentialCount -gt 0) { ($fedCreds | ForEach-Object { $_.Issuer }) -join ";" } else { "None" }
                $FederatedSubjects = if ($FederatedCredentialCount -gt 0) { ($fedCreds | ForEach-Object { $_.Subject }) -join ";" } else { "None" }
                $UsedByExternalSystem = $FederatedCredentialCount -gt 0
                $ExternalSystemType = Get-PSUExternalSystemType -Issuers $fedCreds.Issuer
                #endregion

                #region Signal E: Interactive Sign-in Logs (Optional)
                $LastInteractiveSignInDate = $null
                $LastSignInResourceName = "NoData"
                $LastSignInUserUPN = "NoData"
                $LastSignInIPAddress = "NoData"
                $LastSignInLocation = "NoData"
                $LastSignInClientApp = "NoData"
                $LegacyAuthDetected = $false
                $IsCoveredByCAPolicy = $CaProtectedAppIds.Contains($AppId)

                if (-not $SkipSignInLogs -and -not $NoServicePrincipal) {
                    $siDate = (Get-Date).AddDays(-30).ToString("yyyy-MM-ddTHH:mm:ssZ")
                    $signIn = Invoke-PSUGraphWithRetry -OperationName "InteractiveSignIn-$AppId" -ScriptBlock {
                        Get-MgAuditLogSignIn `
                            -Filter "appId eq '$AppId' and createdDateTime ge $siDate" `
                            -Top 1 -OrderBy "createdDateTime desc" `
                            -Property @(
                            "createdDateTime", "resourceDisplayName", "resourceId",
                            "userPrincipalName", "ipAddress", "location",
                            "clientAppUsed", "conditionalAccessStatus", "status"
                        )
                    } -LogFunction $LogFunc

                    if ($null -ne $signIn) {
                        $LastInteractiveSignInDate = $signIn.CreatedDateTime
                        $LastSignInResourceName = $signIn.ResourceDisplayName ?? "NoData"
                        $LastSignInUserUPN = $signIn.UserPrincipalName ?? "NoData"
                        $LastSignInIPAddress = $signIn.IpAddress ?? "NoData"
                        $loc = $signIn.Location
                        $LastSignInLocation = if ($loc) { "$($loc.City), $($loc.CountryOrRegion)" } else { "NoData" }
                        $LastSignInClientApp = $signIn.ClientAppUsed ?? "NoData"
                        $LegacyAuthDetected = $LastSignInClientApp -match "Basic Auth|SMTP|POP3|IMAP|MAPI|Exchange ActiveSync|Other clients"
                    }

                    if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
                }
                #endregion

                #region Signal F: SP/Daemon Sign-in Logs (Optional)
                $LastSPSignInDate = $null
                $LastSPSignInResourceName = "NoData"
                $DaemonUsageDetected = $false

                if (-not $SkipSignInLogs -and -not $NoServicePrincipal) {
                    $spSiDate = (Get-Date).AddDays(-7).ToString("yyyy-MM-ddTHH:mm:ssZ")
                    $spSignIn = Invoke-PSUGraphWithRetry -OperationName "SPSignIn-$AppId" -ScriptBlock {
                        Get-MgAuditLogSignIn `
                            -Filter "appId eq '$AppId' and signInEventTypes/any(t: t eq 'servicePrincipal') and createdDateTime ge $spSiDate" `
                            -Top 1 -OrderBy "createdDateTime desc" `
                            -Property @("createdDateTime", "resourceDisplayName", "ipAddress", "status")
                    } -LogFunction $LogFunc

                    if ($null -ne $spSignIn) {
                        $LastSPSignInDate = $spSignIn.CreatedDateTime
                        $LastSPSignInResourceName = $spSignIn.ResourceDisplayName ?? "NoData"
                        $DaemonUsageDetected = $true
                    }

                    if ($ThrottleDelayMs -gt 0) { Start-Sleep -Milliseconds $ThrottleDelayMs }
                }
                #endregion

                #region Signal G: Outbound App Role Assignments
                $outbound = if ($ServicePrincipalId -ne "NoSP") { $OutboundRoleCache[$ServicePrincipalId] } else { $null }
                $AppRoleAssignmentCount = if ($outbound) { $outbound.Count } else { 0 }
                $AppRoleAssignedResources = if ($outbound) { ($outbound | ForEach-Object { $_.ResourceDisplayName } | Select-Object -Unique) -join ";" } else { "None" }
                #endregion

                #region Signal H: Inbound Consumers
                $inbound = if ($ServicePrincipalId -ne "NoSP") { $InboundRoleCache[$ServicePrincipalId] } else { $null }

                $consumingApps = $inbound | Where-Object { $_.PrincipalType -eq "ServicePrincipal" }
                $assignedUsers = $inbound | Where-Object { $_.PrincipalType -eq "User" }
                $assignedGroups = $inbound | Where-Object { $_.PrincipalType -eq "Group" }

                $ConsumedByAppsCount = ($consumingApps | Measure-Object).Count
                $ConsumedByApps = ($consumingApps | ForEach-Object { $_.PrincipalDisplayName }) -join ";"
                $AssignedUsersCount = ($assignedUsers | Measure-Object).Count
                $AssignedGroupsCount = ($assignedGroups | Measure-Object).Count
                $AssignedGroupNames = ($assignedGroups | ForEach-Object { $_.PrincipalDisplayName }) -join ";"
                $IsActingAsResourceApp = $ConsumedByAppsCount -gt 0
                $DeletionBlastRadius = $ConsumedByAppsCount + $AssignedUsersCount + $AssignedGroupsCount
                #endregion

                #region Signal I: OAuth2 Permission Grants
                $grants = if ($ServicePrincipalId -ne "NoSP") { $OAuthGrantCache[$ServicePrincipalId] } else { $null }
                $OAuthGrantCount = if ($grants) { $grants.Count } else { 0 }
                $OAuthGrantScopes = if ($grants) { ($grants | ForEach-Object { $_.Scope }) -join ";" } else { "None" }
                $AdminConsentGranted = ($grants | Where-Object { $_.ConsentType -eq "AllPrincipals" }).Count -gt 0
                #endregion

                #region Signal J: Directory Role Assignments
                $dirRoles = if ($ServicePrincipalId -ne "NoSP") { $DirRoleCache[$ServicePrincipalId] } else { $null }
                $DirectoryRoles = if ($dirRoles) { ($dirRoles | ForEach-Object { $_.RoleDefinition.DisplayName }) -join ";" } else { "None" }
                $DirectoryRoleCount = if ($dirRoles) { $dirRoles.Count } else { 0 }
                $HasPrivilegedEntraRole = ($dirRoles | Where-Object {
                        $_.RoleDefinition.DisplayName -in $PrivilegedEntraRoles
                    }).Count -gt 0
                #endregion

                #region Signal K: Azure RBAC
                $rbac = if ($ServicePrincipalId -ne "NoSP") { $AzureRbacCache[$ServicePrincipalId] } else { $null }
                $AzureRBACRoleCount = if ($rbac) { $rbac.Count } else { 0 }
                $AzureRBACRoles = if ($rbac) { ($rbac | ForEach-Object { $_.RoleDefinitionName } | Select-Object -Unique) -join ";" } else { "None" }
                $AzureRBACScopes = if ($rbac) { ($rbac | ForEach-Object { $_.Scope } | Select-Object -Unique) -join ";" } else { "None" }
                $HasAzureRBACRoles = $AzureRBACRoleCount -gt 0
                $HasPrivilegedAzureRole = ($rbac | Where-Object { $_.RoleDefinitionName -in $PrivilegedAzureRoles }).Count -gt 0
                #endregion

                #region Signal L: Credential Health + Secret Age
                $now = Get-Date

                $secrets = $app.PasswordCredentials
                $SecretCount = $secrets.Count
                $HasExpiredSecret = ($secrets | Where-Object { $_.EndDateTime -lt $now }).Count -gt 0
                $NearestSecretExpiry = if ($SecretCount -gt 0) { ($secrets | Sort-Object EndDateTime | Select-Object -First 1).EndDateTime } else { "NoSecret" }
                $OldestSecretAgeDays = if ($SecretCount -gt 0) {
                    $oldest = ($secrets | Sort-Object StartDateTime | Select-Object -First 1).StartDateTime
                    if ($null -ne $oldest) { [int]($now - $oldest).TotalDays } else { -1 }
                } else { -1 }

                $AllSecretsExpired = ($SecretCount -gt 0 -and
                    ($secrets | Where-Object { $_.EndDateTime -ge $now }).Count -eq 0)

                $certs = $app.KeyCredentials
                $CertCount = $certs.Count
                $HasExpiredCert = ($certs | Where-Object { $_.EndDateTime -lt $now }).Count -gt 0
                $NearestCertExpiry = if ($CertCount -gt 0) { ($certs | Sort-Object EndDateTime | Select-Object -First 1).EndDateTime } else { "NoCert" }
                $AllCertsExpired = ($CertCount -gt 0 -and
                    ($certs | Where-Object { $_.EndDateTime -ge $now }).Count -eq 0)

                $UsesPasswordlessAuth = ($FederatedCredentialCount -gt 0 -or $CertCount -gt 0 -or
                    $ServicePrincipalType -eq "ManagedIdentity")
                $AuthMethod = if ($ServicePrincipalType -eq "ManagedIdentity") { "ManagedIdentity" }
                elseif ($FederatedCredentialCount -gt 0) { "FederatedCredential" }
                elseif ($CertCount -gt 0 -and $SecretCount -eq 0) { "CertificateOnly" }
                elseif ($SecretCount -gt 0) { "ClientSecret" }
                else { "NoCredential" }
                #endregion

                #region Signal M: Broad Permission Detection
                $HasBroadGraphPermissions = $false
                $BroadPermissionNames = [System.Collections.Generic.List[string]]::new()

                foreach ($resource in $app.RequiredResourceAccess) {
                    if ($resource.ResourceAppId -eq $graphResourceId) {
                        foreach ($access in $resource.ResourceAccess) {
                            if ($BroadPermissions.ContainsKey($access.Id.ToString())) {
                                $HasBroadGraphPermissions = $true
                                $BroadPermissionNames.Add($BroadPermissions[$access.Id.ToString()])
                            }
                        }
                    }
                }
                #endregion

                #region Scoring, Classification, Bucketing
                $signalHash = @{
                    IsMicrosoftApp            = $IsMicrosoftApp
                    ConsumedByAppsCount       = $ConsumedByAppsCount
                    HasPrivilegedEntraRole    = $HasPrivilegedEntraRole
                    HasPrivilegedAzureRole    = $HasPrivilegedAzureRole
                    AssignedUsersCount        = $AssignedUsersCount
                    SPLastSignInDate          = $SPLastSignInDate
                    SPLastDaemonSignInDate    = $SPLastDaemonSignInDate
                    LastInteractiveSignInDate = $LastInteractiveSignInDate
                    DaemonUsageDetected       = $DaemonUsageDetected
                    UsedByExternalSystem      = $UsedByExternalSystem
                    ExternalSystemType        = $ExternalSystemType
                    HasAzureRBACRoles         = $HasAzureRBACRoles
                    AzureRBACRoleCount        = $AzureRBACRoleCount
                    AssignedGroupsCount       = $AssignedGroupsCount
                    AdminConsentGranted       = $AdminConsentGranted
                    AppRoleAssignmentCount    = $AppRoleAssignmentCount
                    OAuthGrantCount           = $OAuthGrantCount
                    IsApiProvider             = $IsApiProvider
                    ExposedScopeCount         = $ExposedScopeCount
                    DirectoryRoleCount        = $DirectoryRoleCount
                    HasExpiredSecret          = $HasExpiredSecret
                    SecretCount               = $SecretCount
                    HasExpiredCert            = $HasExpiredCert
                    CertCount                 = $CertCount
                    IsCoveredByCAPolicy       = $IsCoveredByCAPolicy
                    NoServicePrincipal        = $NoServicePrincipal
                    AllSecretsExpired         = $AllSecretsExpired
                    AppAgeDays                = $AppAgeDays
                    IsVerifiedPublisher       = $IsVerifiedPublisher
                    FederatedCredentialCount  = $FederatedCredentialCount
                    LastSPSignInDate          = $LastSPSignInDate
                    AppRoleAssignedResources  = $AppRoleAssignedResources
                    ConsumedByApps            = $ConsumedByApps
                    AzureRBACScopes           = $AzureRBACScopes
                    AssignedGroupNames        = $AssignedGroupNames
                    ExposedPermissions        = $ExposedPermissions
                    LastSignInResourceName    = $LastSignInResourceName
                    LastSPSignInResourceName  = $LastSPSignInResourceName
                }

                $scoreResult = Get-PSUDeletionSafetyScore -Signals $signalHash
                $usageResult = Get-PSUAppUsageStatus -Signals $signalHash
                $bucketResult = Get-PSUCleanupBucket -Score $scoreResult.Score `
                    -IsUnused $usageResult.IsUnused `
                    -UsageConfidence $usageResult.UsageConfidence `
                    -IsMicrosoftApp $IsMicrosoftApp
                $whereUsed = Get-PSUAppWhereUsed -Signals $signalHash

                $DaysSinceInteractiveSignIn = if ($LastInteractiveSignInDate -is [datetime]) {
                    [int]((Get-Date) - $LastInteractiveSignInDate).TotalDays
                } else { -1 }
                $DaysSinceSPSignIn = if ($LastSPSignInDate -is [datetime]) {
                    [int]((Get-Date) - $LastSPSignInDate).TotalDays
                } else { -1 }
                #endregion

                #region Build Master Record
                $masterRecord = [PSCustomObject]@{
                    # Identity & Classification
                    CleanupBucket              = $bucketResult.Bucket
                    BucketLabel                = $bucketResult.Label
                    RecommendedAction          = $bucketResult.Action
                    DeletionSafetyScore        = $scoreResult.Score
                    ScoreReasons               = $scoreResult.ScoreReasons
                    AppName                    = $DisplayName
                    AppId                      = $AppId
                    ObjectId                   = $ObjectId
                    CreatedDate                = $CreatedDateTime
                    SignInAudience             = $SignInAudience
                    ServicePrincipalId         = $ServicePrincipalId
                    NoServicePrincipal         = $NoServicePrincipal
                    IsEnabled                  = $IsEnabled
                    ServicePrincipalType       = $ServicePrincipalType
                    Tags                       = $Tags
                    VerifiedPublisher          = $VerifiedPublisher
                    PublisherDomain            = $PublisherDomain
                    IsMicrosoftApp             = $IsMicrosoftApp
                    AppAgeDays                 = $AppAgeDays

                    # Ownership
                    OwnerUPNs                  = $OwnerUPNs
                    OwnerCount                 = $OwnerCount
                    HasOwner                   = $HasOwner

                    # Usage Classification
                    IsUnused                   = $usageResult.IsUnused
                    UnusedMoreThan1Year        = $usageResult.UnusedMoreThan1Year
                    UsageConfidence            = $usageResult.UsageConfidence
                    LastUsedDate               = $usageResult.LastUsedDate
                    DaysSinceLastUse           = $usageResult.DaysSinceLastUse
                    UsageLocationSummary       = $whereUsed

                    # Interactive Sign-in
                    LastInteractiveSignInDate  = if ($null -ne $LastInteractiveSignInDate) { $LastInteractiveSignInDate } else { "NoData" }
                    DaysSinceInteractiveSignIn = $DaysSinceInteractiveSignIn
                    LastSignInResourceName     = $LastSignInResourceName
                    LastSignInUserUPN          = $LastSignInUserUPN
                    LastSignInIPAddress        = $LastSignInIPAddress
                    LastSignInLocation         = $LastSignInLocation
                    LastSignInClientApp        = $LastSignInClientApp
                    LegacyAuthDetected         = $LegacyAuthDetected
                    IsCoveredByCAPolicy        = $IsCoveredByCAPolicy

                    # SP Sign-in Activity
                    SPLastSignInDate           = if ($null -ne $SPLastSignInDate) { $SPLastSignInDate } else { "NoData" }
                    SPLastDaemonSignInDate     = if ($null -ne $SPLastDaemonSignInDate) { $SPLastDaemonSignInDate } else { "NoData" }

                    # SP/Daemon Sign-in
                    LastSPSignInDate           = if ($null -ne $LastSPSignInDate) { $LastSPSignInDate } else { "NoData" }
                    DaysSinceSPSignIn          = $DaysSinceSPSignIn
                    LastSPSignInResourceName   = $LastSPSignInResourceName
                    DaemonUsageDetected        = $DaemonUsageDetected

                    # Inbound Consumers
                    DeletionBlastRadius        = $DeletionBlastRadius
                    ConsumedByAppsCount        = $ConsumedByAppsCount
                    ConsumedByApps             = $ConsumedByApps
                    AssignedUsersCount         = $AssignedUsersCount
                    AssignedGroupsCount        = $AssignedGroupsCount
                    AssignedGroupNames         = $AssignedGroupNames
                    IsActingAsResourceApp      = $IsActingAsResourceApp

                    # Outbound API Usage
                    AppRoleAssignmentCount     = $AppRoleAssignmentCount
                    AppRoleAssignedResources   = $AppRoleAssignedResources

                    # Delegated Permissions
                    OAuthGrantCount            = $OAuthGrantCount
                    OAuthGrantScopes           = $OAuthGrantScopes
                    AdminConsentGranted        = $AdminConsentGranted

                    # Directory Roles
                    DirectoryRoles             = $DirectoryRoles
                    DirectoryRoleCount         = $DirectoryRoleCount
                    HasPrivilegedEntraRole     = $HasPrivilegedEntraRole

                    # Azure RBAC
                    AzureRBACRoleCount         = $AzureRBACRoleCount
                    AzureRBACRoles             = $AzureRBACRoles
                    AzureRBACScopes            = $AzureRBACScopes
                    HasAzureRBACRoles          = $HasAzureRBACRoles
                    HasPrivilegedAzureRole     = $HasPrivilegedAzureRole

                    # Federated Credentials
                    FederatedCredentialCount   = $FederatedCredentialCount
                    FederatedCredentials       = $FederatedCredentials
                    FederatedIssuers           = $FederatedIssuers
                    FederatedSubjects          = $FederatedSubjects
                    UsedByExternalSystem       = $UsedByExternalSystem
                    ExternalSystemType         = $ExternalSystemType

                    # Credential Health
                    SecretCount                = $SecretCount
                    NearestSecretExpiry        = $NearestSecretExpiry
                    HasExpiredSecret           = $HasExpiredSecret
                    AllSecretsExpired          = $AllSecretsExpired
                    OldestSecretAgeDays        = $OldestSecretAgeDays
                    CertCount                  = $CertCount
                    NearestCertExpiry          = $NearestCertExpiry
                    HasExpiredCert             = $HasExpiredCert
                    AllCertsExpired            = $AllCertsExpired
                    AuthMethod                 = $AuthMethod
                    UsesPasswordlessAuth       = $UsesPasswordlessAuth

                    # Application Architecture
                    IsPublicClient             = $IsPublicClient
                    IsApiProvider              = $IsApiProvider
                    ExposedScopeCount          = $ExposedScopeCount
                    ExposedRoleCount           = $ExposedRoleCount
                    ExposedPermissions         = $ExposedPermissions
                    DeclaredPermissionCount    = $DeclaredPermissionCount
                    HasBroadGraphPermissions   = $HasBroadGraphPermissions
                    BroadPermissionNames       = ($BroadPermissionNames -join ";")
                    RedirectUriCount           = $RedirectUriCount
                    WebRedirectUris            = $WebRedirectUris
                    SpaRedirectUris            = $SpaRedirectUris
                    PublicClientUris           = $PublicClientUris
                    HasLocalhostUri            = $HasLocalhostUri
                    HasHttpUri                 = $HasHttpUri
                    HasNgrokUri                = $HasNgrokUri
                }

                $MasterReport.Add($masterRecord)
                Write-Log "DEBUG" "App" "[$currentApp/$totalApps] $AppId | Score=$($scoreResult.Score) | $($bucketResult.Label)"
                #endregion
            }

            Write-Progress -Activity "Auditing App Registrations" -Completed
            Write-Log "INFO" "Phase2" "Completed per-app signal collection for $totalApps apps"

            #endregion

            #region Phase 3: Managed Identity Sweep

            Write-Log "INFO" "MI" "Starting Managed Identity governance sweep ($($MiCache.Count) MIs)..."
            $MiReport = [System.Collections.Generic.List[object]]::new()

            foreach ($mi in $MiCache.Values) {
                $miRbac = $AzureRbacCache[$mi.Id]
                $miDirRole = $DirRoleCache[$mi.Id]

                $miRBACRoles = if ($miRbac) { ($miRbac | ForEach-Object { $_.RoleDefinitionName }) -join ";" } else { "None" }
                $miDirRoles = if ($miDirRole) { ($miDirRole | ForEach-Object { $_.RoleDefinition.DisplayName }) -join ";" } else { "None" }

                # Determine MI type from AlternativeNames
                $altNames = $mi.AlternativeNames
                $isSystemAssigned = ($altNames | Where-Object { $_ -like '/subscriptions/*' }).Count -gt 0
                $miType = if ($isSystemAssigned) { "SystemAssigned" } else { "UserAssigned" }

                $LinkedResource = if ($isSystemAssigned) {
                    ($altNames | Where-Object { $_ -like '/subscriptions/*' }) | Select-Object -First 1
                } else {
                    $mi.DisplayName
                }

                $miRecord = [PSCustomObject]@{
                    DisplayName            = $mi.DisplayName
                    ServicePrincipalId     = $mi.Id
                    AppId                  = $mi.AppId
                    ManagedIdentityType    = $miType
                    LinkedResource         = $LinkedResource
                    IsEnabled              = $mi.AccountEnabled
                    CreatedDateTime        = $mi.CreatedDateTime
                    AzureRBACRoles         = $miRBACRoles
                    AzureRBACRoleCount     = if ($miRbac) { $miRbac.Count } else { 0 }
                    HasPrivilegedAzureRole = ($miRbac | Where-Object { $_.RoleDefinitionName -in $PrivilegedAzureRoles }).Count -gt 0
                    DirectoryRoles         = $miDirRoles
                    HasPrivilegedEntraRole = ($miDirRole | Where-Object { $_.RoleDefinition.DisplayName -in $PrivilegedEntraRoles }).Count -gt 0
                    PossiblyOrphaned       = ($miRbac.Count -eq 0 -and $miDirRole.Count -eq 0)
                }
                $MiReport.Add($miRecord)
            }
            Write-Log "INFO" "MI" "Completed MI sweep: $($MiReport.Count) Managed Identities processed"

            #endregion

            #region Phase 4: Export CSVs

            Write-Log "INFO" "Export" "Exporting output files..."

            # Master Audit CSV
            $masterPath = Join-Path $OutputDirectory "MasterAudit_Full_${TenantId}_${DateStamp}.csv"
            $MasterReport | Export-Csv -Path $masterPath -NoTypeInformation -Encoding UTF8
            Write-Log "INFO" "Export" "Master Audit: $masterPath"

            # Bucket CSVs
            for ($b = 1; $b -le 4; $b++) {
                $bucketLabels = @{ 1 = "Bucket1_SafeToDisable"; 2 = "Bucket2_NeedsInvestigation"; 3 = "Bucket3_LikelyActive"; 4 = "Bucket4_BusinessCritical" }
                $bucketApps = $MasterReport | Where-Object { $_.CleanupBucket -eq $b }
                $bucketPath = Join-Path $OutputDirectory "$($bucketLabels[$b])_${TenantId}_${DateStamp}.csv"

                if ($b -eq 1) {
                    # Add Bucket 1 extra columns
                    $bucketApps | Select-Object *, @{N = 'ProposedDisableDate'; E = { Get-Date -Format 'yyyy-MM-dd' } },
                    @{N = 'ProposedDeleteDate'; E = { (Get-Date).AddDays(30).ToString('yyyy-MM-dd') } },
                    @{N = 'DisabledConfirmed'; E = { '' } },
                    @{N = 'OwnerNotified'; E = { '' } } |
                        Export-Csv -Path $bucketPath -NoTypeInformation -Encoding UTF8
                } else {
                    $bucketApps | Export-Csv -Path $bucketPath -NoTypeInformation -Encoding UTF8
                }
                Write-Log "INFO" "Export" "$($bucketLabels[$b]): $(@($bucketApps).Count) apps -> $bucketPath"
            }

            # Managed Identity CSV
            $miPath = Join-Path $OutputDirectory "ManagedIdentity_Audit_${TenantId}_${DateStamp}.csv"
            $MiReport | Export-Csv -Path $miPath -NoTypeInformation -Encoding UTF8
            Write-Log "INFO" "Export" "Managed Identity: $($MiReport.Count) MIs -> $miPath"

            # Owner Notification CSVs
            $byOwner = $MasterReport | Where-Object { $_.OwnerUPNs -ne "NoOwner" } |
                ForEach-Object {
                    $currentAppRecord = $_
                    $currentAppRecord.OwnerUPNs -split ";" | ForEach-Object {
                        @{ Owner = $_; App = $currentAppRecord }
                    }
                } | Group-Object -Property Owner

            foreach ($ownerGroup in $byOwner) {
                $safeOwnerFileName = $ownerGroup.Name -replace '[\\/:*?"<>|@]', '_'
                $ownerApps = $ownerGroup.Group | Select-Object -ExpandProperty App
                $ownerPath = Join-Path $ownerNotifDir "Owner_${safeOwnerFileName}_${DateStamp}.csv"
                $ownerApps | Select-Object $OwnerNotificationColumns |
                    Export-Csv -Path $ownerPath -NoTypeInformation -Encoding UTF8
            }
            Write-Log "INFO" "Export" "Owner Notifications: $(@($byOwner).Count) owner files -> $ownerNotifDir"

            # NoOwner Escalation
            $noOwnerApps = $MasterReport | Where-Object { $_.OwnerUPNs -eq "NoOwner" -and $_.CleanupBucket -le 2 }
            $escalationPath = Join-Path $ownerNotifDir "NoOwner_Escalation_${DateStamp}.csv"
            $noOwnerApps | Select-Object $OwnerNotificationColumns |
                Export-Csv -Path $escalationPath -NoTypeInformation -Encoding UTF8
            Write-Log "INFO" "Export" "NoOwner Escalation: $(@($noOwnerApps).Count) apps -> $escalationPath"

            #endregion

            #region Phase 5: Console Summary

            $duration = (Get-Date) - $ScriptStartTime
            $durationStr = "$([int]$duration.TotalMinutes) min $($duration.Seconds) sec"

            $bucket1Count = @($MasterReport | Where-Object { $_.CleanupBucket -eq 1 }).Count
            $bucket2Count = @($MasterReport | Where-Object { $_.CleanupBucket -eq 2 }).Count
            $bucket3Count = @($MasterReport | Where-Object { $_.CleanupBucket -eq 3 }).Count
            $bucket4Count = @($MasterReport | Where-Object { $_.CleanupBucket -eq 4 }).Count

            $withOwners = @($MasterReport | Where-Object { $_.HasOwner }).Count
            $withoutOwners = @($MasterReport | Where-Object { -not $_.HasOwner }).Count

            $highConf = @($MasterReport | Where-Object { $_.UsageConfidence -eq "High" }).Count
            $medConf = @($MasterReport | Where-Object { $_.UsageConfidence -eq "Medium" }).Count
            $lowConf = @($MasterReport | Where-Object { $_.UsageConfidence -eq "Low" }).Count

            $withInteractive = @($MasterReport | Where-Object { $_.LastInteractiveSignInDate -ne "NoData" }).Count
            $withDaemon = @($MasterReport | Where-Object { $_.DaemonUsageDetected }).Count
            $withRBAC = @($MasterReport | Where-Object { $_.HasAzureRBACRoles }).Count
            $withExternalCI = @($MasterReport | Where-Object { $_.UsedByExternalSystem }).Count
            $withConsumers = @($MasterReport | Where-Object { $_.ConsumedByAppsCount -gt 0 }).Count

            $privEntra = @($MasterReport | Where-Object { $_.HasPrivilegedEntraRole }).Count
            $privAzure = @($MasterReport | Where-Object { $_.HasPrivilegedAzureRole }).Count
            $broadPerms = @($MasterReport | Where-Object { $_.HasBroadGraphPermissions }).Count
            $expiredSecrets = @($MasterReport | Where-Object { $_.HasExpiredSecret }).Count
            $oldSecrets = @($MasterReport | Where-Object { $_.OldestSecretAgeDays -gt 365 }).Count
            $legacyAuth = @($MasterReport | Where-Object { $_.LegacyAuthDetected }).Count
            $publicBroad = @($MasterReport | Where-Object { $_.IsPublicClient -and $_.HasBroadGraphPermissions }).Count

            Write-Host ""
            Write-Host ([string]::new([char]0x2550, 66)) -ForegroundColor Cyan
            Write-Host " Azure App Registration Cleanup System - v5 Summary" -ForegroundColor Cyan
            Write-Host ([string]::new([char]0x2550, 66)) -ForegroundColor Cyan
            Write-Host " Tenant ID : $TenantId"
            Write-Host " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
            Write-Host " Duration : $durationStr"
            Write-Host " Subscriptions Scanned : $($AzSubscriptions.Count)"
            Write-Host ([string]::new([char]0x2500, 66)) -ForegroundColor DarkGray
            Write-Host " INVENTORY" -ForegroundColor White
            Write-Host " Total App Registrations : $($MasterReport.Count)"
            Write-Host " Managed Identities (separate): $($MiReport.Count)"
            Write-Host ([string]::new([char]0x2500, 66)) -ForegroundColor DarkGray
            Write-Host " CLEANUP PIPELINE RESULTS" -ForegroundColor White
            Write-Host " Bucket 1 - Safe to Disable : $bucket1Count (Score 80-100)" -ForegroundColor Red
            Write-Host " Bucket 2 - Needs Investigation: $bucket2Count (Score 50-79)" -ForegroundColor Yellow
            Write-Host " Bucket 3 - Likely Active : $bucket3Count (Score 20-49)" -ForegroundColor DarkYellow
            Write-Host " Bucket 4 - Business Critical : $bucket4Count (Score 0-19)" -ForegroundColor Green
            Write-Host ([string]::new([char]0x2500, 66)) -ForegroundColor DarkGray
            Write-Host " OWNERSHIP" -ForegroundColor White
            Write-Host " Apps With Owners : $withOwners"
            Write-Host " Apps Without Owners : $withoutOwners"
            Write-Host " Owner Notification Files : $(@($byOwner).Count)"
            Write-Host " Ownerless Escalation Apps : $(@($noOwnerApps).Count)"
            Write-Host ([string]::new([char]0x2500, 66)) -ForegroundColor DarkGray
            Write-Host " USAGE SIGNAL COVERAGE" -ForegroundColor White
            Write-Host " Confirmed Active (High) : $highConf"
            Write-Host " Likely Active (Medium) : $medConf"
            Write-Host " No Signal Found (Low) : $lowConf"
            Write-Host " Apps w/ Interactive Sign-in : $withInteractive"
            Write-Host " Apps w/ Daemon Sign-in : $withDaemon"
            Write-Host " Apps w/ Azure RBAC Roles : $withRBAC"
            Write-Host " Apps Used by External CI/CD : $withExternalCI"
            Write-Host " Apps Consumed by Other Apps : $withConsumers"
            Write-Host ([string]::new([char]0x2500, 66)) -ForegroundColor DarkGray
            Write-Host " RISK FLAGS" -ForegroundColor White
            Write-Host " Apps w/ Privileged Entra Role : $privEntra"
            Write-Host " Apps w/ Privileged Azure Role : $privAzure"
            Write-Host " Apps w/ Broad Graph Perms : $broadPerms"
            Write-Host " Apps w/ Expired Secrets : $expiredSecrets"
            Write-Host " Apps w/ Secrets > 1yr Old : $oldSecrets"
            Write-Host " Apps w/ Legacy Auth : $legacyAuth"
            Write-Host " Public Clients w/ Broad Perms : $publicBroad"
            Write-Host ([string]::new([char]0x2500, 66)) -ForegroundColor DarkGray
            Write-Host " OUTPUT FILES" -ForegroundColor White
            Write-Host " Master Audit : $masterPath"
            Write-Host " Managed IDs : $miPath"
            Write-Host " Owner Files : $ownerNotifDir"
            Write-Host " Log File : $($script:LogFile)"
            Write-Host ([string]::new([char]0x2550, 66)) -ForegroundColor Cyan

            Write-Log "INFO" "Summary" "Audit complete. Duration: $durationStr. Apps: $($MasterReport.Count). B1=$bucket1Count B2=$bucket2Count B3=$bucket3Count B4=$bucket4Count"

            #endregion

            # Return the master report for pipeline usage
            return $MasterReport

        } finally {
            # Note: Do NOT auto-disconnect here. The caller (orchestration script or
            # interactive session) owns the Graph/Azure sessions and may need them
            # after this function completes. Auto-disconnecting forces re-auth on
            # every retry attempt, which is hostile to the user.
            Write-Log "INFO" "Cleanup" "Audit function completed. Graph/Azure sessions left open for caller."
        }
    }

    end {
        Write-Verbose "[$($MyInvocation.MyCommand.Name)] Complete"
    }
}