Private/RoleManagement/Invoke-PIMRoleActivation.ps1

function Invoke-PIMRoleActivation {
    <#
    .SYNOPSIS
        Activates selected PIM (Privileged Identity Management) roles with enhanced error handling and policy compliance.
     
    .DESCRIPTION
        Handles the complete PIM role activation process including:
        - Policy requirement validation (justification, tickets, MFA, authentication context)
        - Duration calculations based on role policies
        - Authentication context challenges for conditional access policies
        - Both Entra ID directory roles and PIM-enabled groups
        - Comprehensive error handling with user-friendly messages
         
        The function supports both standard Microsoft Graph SDK calls and direct REST API calls
        for roles requiring authentication context tokens.
     
    .PARAMETER CheckedItems
        Array of checked ListView items representing the roles to activate.
        Each item must have a Tag property containing role metadata.
     
    .PARAMETER Form
        Reference to the main Windows Forms object for UI updates and refresh operations.
     
    .EXAMPLE
        Invoke-PIMRoleActivation -CheckedItems $selectedRoles -Form $mainForm
         
        Activates the selected PIM roles with appropriate policy validation.
     
    .NOTES
        - Requires Microsoft Graph PowerShell SDK
        - Supports authentication context challenges for conditional access
        - Handles both directory roles and group memberships
        - Duration is automatically adjusted based on role policy limits
        - Uses script-scoped variables for authentication state management
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$CheckedItems,
        
        [Parameter(Mandatory)]
        [System.Windows.Forms.Form]$Form
    )
    
    Write-Verbose "Starting activation process for $($CheckedItems.Count) role(s)"
    
    # Initialize the splash form variable
    $operationSplash = $null
    
    try {
        # Initialize duration from script variable or use default
        $requestedHours = 8
        $requestedMinutes = 0
        
        if ($script:RequestedDuration) {
            $requestedHours = $script:RequestedDuration.Hours
            $requestedMinutes = $script:RequestedDuration.Minutes
        }
        else {
            # Get from form controls if available
            $cmbHours = $Form.Controls.Find("cmbHours", $true)[0]
            $cmbMinutes = $Form.Controls.Find("cmbMinutes", $true)[0]
            
            if ($cmbHours -and $cmbMinutes) {
                $requestedHours = [int]$cmbHours.SelectedItem
                $requestedMinutes = [int]$cmbMinutes.SelectedItem
            }
        }
        
        $requestedTotalMinutes = ($requestedHours * 60) + $requestedMinutes
        Write-Verbose "Using requested duration: $requestedHours hours, $requestedMinutes minutes"

        # Analyze policy requirements across all selected roles
        $policyRequirements = @{
            RequiresJustification = $false
            RequiresTicket        = $false
            RequiresMfa           = $false
            RequiresAuthContext   = $false
            AuthContextIds        = @()
        }
        
        foreach ($item in $CheckedItems) {
            $roleData = $item.Tag
            if ($roleData.PolicyInfo) {
                if ($roleData.PolicyInfo.RequiresJustification) { $policyRequirements.RequiresJustification = $true }
                if ($roleData.PolicyInfo.RequiresTicket) { $policyRequirements.RequiresTicket = $true }
                if ($roleData.PolicyInfo.RequiresMfa) { $policyRequirements.RequiresMfa = $true }
                if ($roleData.PolicyInfo.RequiresAuthenticationContext -and $roleData.PolicyInfo.AuthenticationContextId) {
                    $policyRequirements.RequiresAuthContext = $true
                    $policyRequirements.AuthContextIds += $roleData.PolicyInfo.AuthenticationContextId
                }
            }
        }
        
        # Remove duplicate authentication contexts
        $policyRequirements.AuthContextIds = @($policyRequirements.AuthContextIds | Select-Object -Unique)
        
        Write-Verbose "Policy analysis complete - Justification: $($policyRequirements.RequiresJustification), Ticket: $($policyRequirements.RequiresTicket), MFA: $($policyRequirements.RequiresMfa), Auth Context: $($policyRequirements.RequiresAuthContext)"
        
        # Collect justification and ticket information
        $justification = "PowerShell activation"
        $ticketInfo = $null  # Initialize as null instead of empty hashtable
        
        # Show activation dialog for required or optional information
        if ($policyRequirements.RequiresJustification -or $policyRequirements.RequiresTicket -or $CheckedItems.Count -gt 0) {
            Write-Verbose "Showing activation dialog for justification/ticket requirements"
            $result = Show-PIMActivationDialog -RequiresJustification:$policyRequirements.RequiresJustification `
                -RequiresTicket:$policyRequirements.RequiresTicket `
                -OptionalJustification:$(-not $policyRequirements.RequiresJustification)
            
            if ($result.Cancelled) {
                Write-Verbose "User cancelled activation"
                return
            }
            
            $justification = $result.Justification
            if ($result.TicketNumber) {
                $ticketInfo = @{
                    ticketNumber = $result.TicketNumber
                    ticketSystem = $result.TicketSystem
                }
            }
        }
        
        # Group roles by authentication context to minimize authentication prompts
        $rolesByContext = @{}
        $noContextRoles = @()
        
        foreach ($item in $CheckedItems) {
            $roleData = $item.Tag
            
            if ($roleData.PolicyInfo -and $roleData.PolicyInfo.RequiresAuthenticationContext -and $roleData.PolicyInfo.AuthenticationContextId) {
                $contextId = $roleData.PolicyInfo.AuthenticationContextId
                
                if (-not $rolesByContext.ContainsKey($contextId)) {
                    $rolesByContext[$contextId] = @()
                }
                $rolesByContext[$contextId] += $item
            }
            else {
                $noContextRoles += $item
            }
        }
        
        Write-Verbose "Roles grouped by authentication context: $($rolesByContext.Keys.Count) contexts, $($noContextRoles.Count) without context"

        # NOW show the splash form after all user input has been collected
        $operationSplash = Show-OperationSplash -Title "Role Activation" -InitialMessage "Processing role activations..." -ShowProgressBar $true
        $activationErrors = @()
        $successCount = 0
        $totalRoles = $CheckedItems.Count
        $currentRole = 0
        
        # Process roles that require authentication context first, grouped by context
        foreach ($contextId in $rolesByContext.Keys) {
            Write-Verbose "Processing roles for authentication context: $contextId"
            
            # Try to get authentication context token once per context (reuse for multiple roles)
            $authContextToken = Get-AuthenticationContextToken -ContextId $contextId
            
            if (-not $authContextToken) {
                Write-Warning "Failed to obtain authentication context token for context: $contextId. Falling back to individual token acquisition per role."
                
                # Fallback: Process each role individually using the original method
                foreach ($item in $rolesByContext[$contextId]) {
                    $currentRole++
                    $roleData = $item.Tag
                    $progressPercent = [int](($currentRole / $totalRoles) * 100)
                    
                    if ($operationSplash -and -not $operationSplash.IsDisposed) {
                        $operationSplash.UpdateStatus("Activating $($roleData.DisplayName)... ($currentRole of $totalRoles)", $progressPercent)
                    }
                    
                    Write-Verbose "Processing: $($roleData.DisplayName) [$($roleData.Type)] with individual auth context token acquisition"
                    
                    # Calculate actual duration based on policy
                    $effectiveDuration = Get-EffectiveDuration -RequestedMinutes $requestedTotalMinutes -MaxDurationHours $roleData.PolicyInfo.MaxDuration
                    Write-Verbose "Activation duration: $($effectiveDuration.Hours) hours, $($effectiveDuration.Minutes) minutes"
                    
                    # Use consolidated activation function with fallback method
                    try {
                        $result = Invoke-SingleRoleActivation -RoleData $roleData -Justification $justification -EffectiveDuration $effectiveDuration -TicketInfo $ticketInfo -AuthenticationContextId $contextId -UseFallbackMethod
                        
                        # Handle the result
                        if ($result.Success) {
                            if ($result.IsAzureResource) {
                                # Azure Resource roles handle their own success counting
                                $successCount++
                            }
                            else {
                                Write-Verbose "Role activated via fallback method - Response ID: $($result.Response.id)"
                                $successCount++
                            }
                        }
                        else {
                            if ($result.IsAzureResource) {
                                # Azure Resource errors are already handled in the function
                                $activationErrors += "$($roleData.DisplayName): $($result.ErrorMessage)"
                            }
                            else {
                                $friendlyError = Get-FriendlyErrorMessage -Exception $result.Error.Exception -ErrorDetails $result.ErrorDetails
                                $activationErrors += "$($roleData.DisplayName): $friendlyError"
                                Write-Warning "Failed to activate $($roleData.DisplayName): $friendlyError"
                            }
                        }
                    }
                    catch {
                        $activationErrors += "$($roleData.DisplayName): $($_.Exception.Message)"
                        Write-Warning "Failed to activate $($roleData.DisplayName): $($_.Exception.Message)"
                    }
                }
                continue
            }
            
            Write-Verbose "Successfully obtained authentication context token for context: $contextId"
            
            # Process each role requiring this authentication context using the cached token
            foreach ($item in $rolesByContext[$contextId]) {
                $currentRole++
                $roleData = $item.Tag
                $progressPercent = [int](($currentRole / $totalRoles) * 100)
                
                if ($operationSplash -and -not $operationSplash.IsDisposed) {
                    $operationSplash.UpdateStatus("Activating $($roleData.DisplayName)... ($currentRole of $totalRoles)", $progressPercent)
                }
                
                Write-Verbose "Processing: $($roleData.DisplayName) [$($roleData.Type)] with cached auth context token"
                
                # Calculate actual duration based on policy
                $effectiveDuration = Get-EffectiveDuration -RequestedMinutes $requestedTotalMinutes -MaxDurationHours $roleData.PolicyInfo.MaxDuration
                Write-Verbose "Activation duration: $($effectiveDuration.Hours) hours, $($effectiveDuration.Minutes) minutes"
                
                # Use consolidated activation function with cached authentication context token
                $result = Invoke-SingleRoleActivation -RoleData $roleData -Justification $justification -EffectiveDuration $effectiveDuration -TicketInfo $ticketInfo -AuthContextToken $authContextToken
                
                # Handle the result
                if ($result.Success) {
                    if ($result.IsAzureResource) {
                        # Azure Resource activation already incremented success count
                        Write-Verbose "Azure Resource role activated with authentication context successfully"
                    }
                    else {
                        Write-Verbose "$($roleData.Type) role activated with authentication context - Response ID: $($result.Response.id)"
                        $successCount++
                    }
                }
                else {
                    if ($result.IsAzureResource) {
                        # Azure Resource errors are already added to activationErrors
                        $activationErrors += "$($roleData.DisplayName): $($result.ErrorMessage)"
                    }
                    else {
                        # Log detailed error information
                        Write-Verbose "Activation failed. Error details:"
                        Write-Verbose "Exception: $($result.Error.Exception.Message)"
                        Write-Verbose "Error Details: $($result.ErrorDetails)"
                        
                        $friendlyError = Get-FriendlyErrorMessage -Exception $result.Error.Exception -ErrorDetails $result.ErrorDetails
                        $activationErrors += "$($roleData.DisplayName): $friendlyError"
                        Write-Warning "Failed to activate $($roleData.DisplayName): $friendlyError"
                    }
                }
            }
        }
        
        # Process roles without authentication context
        foreach ($item in $noContextRoles) {
            $currentRole++
            $roleData = $item.Tag
            $progressPercent = [int](($currentRole / $totalRoles) * 100)
            
            if ($operationSplash -and -not $operationSplash.IsDisposed) {
                $operationSplash.UpdateStatus("Activating $($roleData.DisplayName)... ($currentRole of $totalRoles)", $progressPercent)
            }
            
            Write-Verbose "Processing: $($roleData.DisplayName) [$($roleData.Type)]"
            
            # Calculate actual duration based on policy
            $effectiveDuration = Get-EffectiveDuration -RequestedMinutes $requestedTotalMinutes -MaxDurationHours $roleData.PolicyInfo.MaxDuration
            Write-Verbose "Activation duration: $($effectiveDuration.Hours) hours, $($effectiveDuration.Minutes) minutes"
            
            # Use consolidated activation function
            $result = Invoke-SingleRoleActivation -RoleData $roleData -Justification $justification -EffectiveDuration $effectiveDuration -TicketInfo $ticketInfo
            
            # Handle the result
            if ($result.Success) {
                if ($result.IsAzureResource) {
                    Write-Verbose "Azure Resource role activated successfully"
                    $successCount++
                    # Record expected expiration for display using requested effective duration
                    try {
                        if (-not (Get-Variable -Name 'AzureActiveOverrides' -Scope Script -ErrorAction SilentlyContinue)) { $script:AzureActiveOverrides = @{} }
                        $endUtc = (Get-Date).ToUniversalTime().AddHours($effectiveDuration.Hours).AddMinutes($effectiveDuration.Minutes)
                        # Normalize RoleDefinitionId to GUID-only to ensure key matches across sources
                        $roleDefKey = $roleData.RoleDefinitionId
                        if ($roleDefKey -match "/providers/Microsoft\.Authorization/roleDefinitions/([a-fA-F0-9\-]{36})") {
                            $roleDefKey = $matches[1]
                        }
                        # Ensure FullScope exists for keying
                        $fullScope = $roleData.FullScope
                        if (-not $fullScope) { $fullScope = $roleData.DirectoryScopeId }
                        $overrideKey = "$( $fullScope )|$( $roleDefKey )"
                        $script:AzureActiveOverrides[$overrideKey] = [PSCustomObject]@{ EndDateTime = $endUtc }
                        Write-Verbose "Recorded Azure active override for $overrideKey with expiration $endUtc"
                        # Mark affected scope as dirty for delta refresh
                        if (-not (Get-Variable -Name 'DirtyAzureSubscriptions' -Scope Script -ErrorAction SilentlyContinue)) { $script:DirtyAzureSubscriptions = @() }
                        if (-not (Get-Variable -Name 'DirtyManagementGroups' -Scope Script -ErrorAction SilentlyContinue)) { $script:DirtyManagementGroups = @() }
                        if ($roleData.PSObject.Properties['SubscriptionId'] -and $roleData.SubscriptionId) {
                            $script:DirtyAzureSubscriptions += $roleData.SubscriptionId
                            $script:DirtyAzureSubscriptions = @($script:DirtyAzureSubscriptions | Select-Object -Unique)
                            Write-Verbose "Marked subscription $($roleData.SubscriptionId) as dirty for delta refresh"
                        }
                        # If scope is a management group, mark MG as dirty
                        if ($roleData.PSObject.Properties['FullScope'] -and $roleData.FullScope -match "^/providers/Microsoft\.Management/managementGroups/([^/]+)$") {
                            $mgName = $matches[1]
                            $script:DirtyManagementGroups += $mgName
                            $script:DirtyManagementGroups = @($script:DirtyManagementGroups | Select-Object -Unique)
                            Write-Verbose "Marked management group ${mgName} as dirty for delta refresh"
                        }
                    }
                    catch { Write-Verbose "Failed to record Azure active override: $($_.Exception.Message)" }
                }
                else {
                    Write-Verbose "$($roleData.Type) role activated via Microsoft Graph SDK - Response ID: $($result.Response.id)"
                    $successCount++
                }
            }
            else {
                if ($result.IsAzureResource) {
                    # Azure Resource errors
                    $activationErrors += "$($roleData.DisplayName): $($result.ErrorMessage)"
                    Write-Warning "Failed to activate Azure Resource role $($roleData.DisplayName): $($result.ErrorMessage)"
                }
                else {
                    # Log detailed error information
                    Write-Verbose "Microsoft Graph SDK call failed. Error details:"
                    Write-Verbose "Exception: $($result.Error.Exception.Message)"
                    Write-Verbose "Error Details: $($result.ErrorDetails)"
                    
                    $friendlyError = Get-FriendlyErrorMessage -Exception $result.Error.Exception -ErrorDetails $result.ErrorDetails
                    $activationErrors += "$($roleData.DisplayName): $friendlyError"
                    Write-Warning "Failed to activate $($roleData.DisplayName): $friendlyError"
                }
            }
        }
        
        if ($operationSplash -and -not $operationSplash.IsDisposed) {
            $operationSplash.UpdateStatus("Completing activation process...", 95)
        }
        
        # Clean up authentication context state
        if ($script:JustCompletedAuthContext) {
            $script:JustCompletedAuthContext = $false
            $script:AuthContextCompletionTime = $null
        }
        
        # Display activation results
        Show-ActivationResults -SuccessCount $successCount -TotalCount $CheckedItems.Count -Errors $activationErrors
        
        # Refresh role lists to reflect changes
        if ($operationSplash -and -not $operationSplash.IsDisposed) {
            $operationSplash.UpdateStatus("Refreshing role lists...", 98)
        }
        
        # Immediate refresh only once; Graph/Azure reflect changes near-instantly for Azure RBAC
        if ($successCount -gt 0) {
            # Clear role cache so ActiveOnly refresh pulls fresh data while preserving Azure cache for delta
            Write-Verbose "Clearing role cache to force fresh data retrieval after activation (single refresh, no pagination wait)"
            $script:CachedEligibleRoles = $null
            $script:CachedActiveRoles = $null
            $script:LastRoleFetchTime = $null
        }

        Write-Verbose "Refreshing role data (single attempt)"
        try {
            # Per refresh semantics: only refresh ACTIVE roles, do not re-fetch eligible
            Update-PIMRolesList -Form $Form -RefreshActive
            Write-Verbose "Role lists refreshed successfully"
        }
        catch {
            Write-Warning "Failed to refresh role lists: $_"
        }
        
    }
    finally {
        # Ensure splash is closed
        if ($operationSplash -and -not $operationSplash.IsDisposed) {
            $operationSplash.Close()
        }
    }
    
    Write-Verbose "Activation process completed - Success: $successCount, Errors: $($activationErrors.Count)"
}