Private/RoleManagement/PIMPolicyCache.Helpers.ps1
|
<#
.SYNOPSIS Reads the first available property value from an object. .DESCRIPTION Safely reads a list of candidate property names from either a hashtable or a PowerShell object. This keeps the policy-cache helpers tolerant of live objects, cloned role snapshots, and JSON objects that have been deserialized from disk. Null and blank string values are treated as missing. .PARAMETER InputObject The object or hashtable to inspect. .PARAMETER PropertyNames Candidate property names to check in order. The first non-empty value is returned. .OUTPUTS System.Object. Returns the first matching value, or $null when no usable value exists. #> function Get-PIMPolicyObjectValue { [CmdletBinding()] param( [AllowNull()] [object]$InputObject, [Parameter(Mandatory)] [string[]]$PropertyNames ) if ($null -eq $InputObject) { return $null } foreach ($propertyName in $PropertyNames) { if ($InputObject -is [hashtable] -and $InputObject.ContainsKey($propertyName)) { $value = $InputObject[$propertyName] if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { return $value } } if ($InputObject.PSObject.Properties[$propertyName]) { $value = $InputObject.$propertyName if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { return $value } } } return $null } <# .SYNOPSIS Creates a stable SHA-256 hash for policy-cache values. .DESCRIPTION Converts a string to a lowercase SHA-256 hash. The helper is used to create tenant-scoped cache folder names and build stable policy-content hashes without storing raw tenant identifiers in file paths. .PARAMETER Value The string value to hash. Null values are normalized to an empty string. .OUTPUTS System.String. A lowercase hexadecimal SHA-256 hash. #> function ConvertTo-PIMPolicyCacheHash { [CmdletBinding()] param( [AllowNull()] [string]$Value ) $normalizedValue = if ($null -eq $Value) { '' } else { $Value } $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalizedValue) $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hashBytes = $sha256.ComputeHash($bytes) return ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant() } finally { $sha256.Dispose() } } <# .SYNOPSIS Converts common serialized boolean values to a Boolean. .DESCRIPTION Normalizes Boolean-like values from live policy objects and JSON cache records. The function accepts native Boolean values and common string or numeric representations such as true, false, yes, no, 1, and 0. Unknown values resolve to $false. .PARAMETER Value The value to normalize. .OUTPUTS System.Boolean. #> function ConvertTo-PIMPolicyCacheBool { [CmdletBinding()] param( [AllowNull()] [object]$Value ) if ($null -eq $Value) { return $false } if ($Value -is [bool]) { return $Value } $valueText = [string]$Value if ([string]::IsNullOrWhiteSpace($valueText)) { return $false } if ($valueText -match '^(1|true|yes)$') { return $true } if ($valueText -match '^(0|false|no)$') { return $false } return $false } <# .SYNOPSIS Converts a cache timestamp to UTC. .DESCRIPTION Normalizes DateTime and string timestamp values to UTC. PowerShell can deserialize ISO JSON timestamps back as DateTime objects, and string conversion can become culture-sensitive, so this helper keeps cache freshness checks consistent across locales. .PARAMETER Value The timestamp value to convert. .PARAMETER DefaultValue The UTC fallback value to use when the input cannot be parsed. Defaults to the current UTC time. .OUTPUTS System.DateTime. A UTC DateTime value. #> function ConvertTo-PIMPolicyCacheUtcDateTime { [CmdletBinding()] param( [AllowNull()] [object]$Value, [DateTime]$DefaultValue = ([DateTime]::UtcNow) ) if ($null -eq $Value) { return $DefaultValue.ToUniversalTime() } if ($Value -is [DateTime]) { return $Value.ToUniversalTime() } $valueText = [string]$Value if ([string]::IsNullOrWhiteSpace($valueText)) { return $DefaultValue.ToUniversalTime() } try { $styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal return ([DateTime]::Parse($valueText, [System.Globalization.CultureInfo]::InvariantCulture, $styles)).ToUniversalTime() } catch { try { return ([DateTime]::Parse($valueText)).ToUniversalTime() } catch { return $DefaultValue.ToUniversalTime() } } } <# .SYNOPSIS Resolves the tenant scope for persistent policy cache files. .DESCRIPTION Builds a cache scope from the current Graph tenant. Policy metadata is tenant-wide, so no account information is used for the persistent cache path or metadata. .OUTPUTS PSCustomObject. Contains tenant values and hash-derived cache scope identifiers. #> function Get-PIMPolicyCacheScope { [CmdletBinding()] param() $tenantId = $null if ((Get-Variable -Name 'CurrentTenantId' -Scope Script -ErrorAction SilentlyContinue) -and -not [string]::IsNullOrWhiteSpace([string]$script:CurrentTenantId)) { $tenantId = [string]$script:CurrentTenantId } elseif ((Get-Variable -Name 'GraphContext' -Scope Script -ErrorAction SilentlyContinue) -and $script:GraphContext -and $script:GraphContext.PSObject.Properties['TenantId'] -and $script:GraphContext.TenantId) { $tenantId = [string]$script:GraphContext.TenantId } else { try { $graphContext = Get-MgContext -ErrorAction SilentlyContinue if ($graphContext -and $graphContext.PSObject.Properties['TenantId'] -and $graphContext.TenantId) { $tenantId = [string]$graphContext.TenantId } } catch { } } if ([string]::IsNullOrWhiteSpace($tenantId)) { $tenantId = 'unknown-tenant' } $tenantHash = (ConvertTo-PIMPolicyCacheHash -Value $tenantId).Substring(0, 32) return [PSCustomObject]@{ TenantId = $tenantId TenantHash = $tenantHash ScopeHash = $tenantHash ScopeType = 'Tenant' } } <# .SYNOPSIS Gets the folder used for the current policy-cache scope. .DESCRIPTION Resolves the per-tenant policy-cache directory under %LOCALAPPDATA%\PIMActivation\PolicyCache. The final folder name is a hash of the tenant scope, avoiding raw tenant identifiers in the local file-system path. .PARAMETER Create Creates the directory when it does not already exist. .OUTPUTS System.String. The full cache directory path. #> function Get-PIMPolicyCacheStorePath { [CmdletBinding()] param( [switch]$Create ) $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) if ([string]::IsNullOrWhiteSpace($localAppData)) { $localAppData = $env:LOCALAPPDATA } if ([string]::IsNullOrWhiteSpace($localAppData)) { throw 'LOCALAPPDATA could not be resolved.' } $scope = Get-PIMPolicyCacheScope $cacheRoot = Join-Path (Join-Path $localAppData 'PIMActivation') 'PolicyCache' $cachePath = Join-Path $cacheRoot $scope.ScopeHash if ($Create -and -not (Test-Path -Path $cachePath)) { New-Item -Path $cachePath -ItemType Directory -Force | Out-Null } return $cachePath } <# .SYNOPSIS Gets the persistent policy-cache JSON file path. .DESCRIPTION Returns the policy-cache file path for the current tenant scope. The file stores only sanitized policy requirement metadata, not tokens, authentication-context tokens, display-name lookups, or activation request data. .PARAMETER Create Creates the scoped cache directory before returning the file path. .OUTPUTS System.String. The full path to policy-cache.json. #> function Get-PIMPolicyCacheFilePath { [CmdletBinding()] param( [switch]$Create ) return (Join-Path (Get-PIMPolicyCacheStorePath -Create:$Create) 'policy-cache.json') } <# .SYNOPSIS Builds the in-memory policy-cache key for a role. .DESCRIPTION Produces the same cache-key shape used by the existing policy code for Entra roles, PIM groups, and Azure Resource roles. This centralizes key construction so disk cache, UI refresh, and policy fetch paths all address the same policy entries. .PARAMETER Role A role object, cloned role snapshot, or hashtable containing role identity properties. .OUTPUTS System.String. The cache key, or $null when the role identity cannot be determined. #> function Get-PIMPolicyCacheKey { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Role ) $roleType = [string](Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('Type')) if ($roleType -eq 'Azure') { $roleType = 'AzureResource' } switch ($roleType) { 'Group' { $groupId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('GroupId', 'ResourceId', 'Id') if ($groupId) { return "Group_$groupId" } } 'Entra' { $roleId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('RoleDefinitionId', 'Id') if ($roleId) { return "Entra_$roleId" } } 'AzureResource' { $roleDefinitionId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('RoleDefinitionId', 'Id') if ($roleDefinitionId) { return "AzureResource_$roleDefinitionId" } } default { $roleId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('RoleDefinitionId', 'GroupId', 'ResourceId', 'Id') if ($roleType -and $roleId) { return "${roleType}_$roleId" } } } return $null } <# .SYNOPSIS Converts policy information into a sanitized persistent cache record. .DESCRIPTION Copies only non-secret policy metadata into a JSON-friendly ordered dictionary. Tokens, activation payloads, justifications, ticket values, and runtime authentication details are deliberately excluded. A content hash is included so refreshed policy metadata can be compared without storing raw source objects. .PARAMETER CacheKey The policy-cache key associated with the policy information. .PARAMETER PolicyInfo The live policy information object to persist. .OUTPUTS System.Collections.Specialized.OrderedDictionary. A JSON-safe policy-cache record. #> function ConvertTo-PIMPolicyCacheRecord { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CacheKey, [Parameter(Mandatory)] [object]$PolicyInfo ) $fetchedAtValue = Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('PIMCacheFetchedAt', 'FetchedAt', 'CachedAt') $fetchedAt = ConvertTo-PIMPolicyCacheUtcDateTime -Value $fetchedAtValue $maxDurationValue = Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('MaxDuration') $maxDuration = 8 if ($maxDurationValue) { try { $maxDuration = [int]$maxDurationValue } catch { $maxDuration = 8 } } $hashPayload = [ordered]@{ MaxDuration = $maxDuration RequiresMfa = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('RequiresMfa', 'RequiresMFA')) RequiresJustification = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('RequiresJustification')) RequiresTicket = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('RequiresTicket')) RequiresApproval = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('RequiresApproval')) RequiresAuthenticationContext = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('RequiresAuthenticationContext')) AuthenticationContextId = Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('AuthenticationContextId') AuthenticationContextDisplayName = Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('AuthenticationContextDisplayName') AuthenticationContextDescription = Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('AuthenticationContextDescription') PolicyUnavailable = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('PolicyUnavailable')) } $hash = ConvertTo-PIMPolicyCacheHash -Value ($hashPayload | ConvertTo-Json -Depth 8 -Compress) return [ordered]@{ CacheKey = $CacheKey MaxDuration = $hashPayload.MaxDuration RequiresMfa = $hashPayload.RequiresMfa RequiresJustification = $hashPayload.RequiresJustification RequiresTicket = $hashPayload.RequiresTicket RequiresApproval = $hashPayload.RequiresApproval RequiresAuthenticationContext = $hashPayload.RequiresAuthenticationContext AuthenticationContextId = $hashPayload.AuthenticationContextId AuthenticationContextDisplayName = $hashPayload.AuthenticationContextDisplayName AuthenticationContextDescription = $hashPayload.AuthenticationContextDescription AuthenticationContextDetails = $null PolicyUnavailable = $hashPayload.PolicyUnavailable FetchedAt = $fetchedAt.ToString('o') Hash = $hash } } <# .SYNOPSIS Converts a persisted policy record back to runtime policy information. .DESCRIPTION Rehydrates sanitized policy metadata from disk into the same shape expected by the UI and activation dialog. The returned object is marked as disk-sourced and keeps the cache timestamp/hash for freshness checks and background revalidation. .PARAMETER Record The deserialized JSON policy record. .OUTPUTS PSCustomObject. A policy information object suitable for $script:PolicyCache. #> function ConvertFrom-PIMPolicyCacheRecord { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Record ) $maxDurationValue = Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('MaxDuration') $maxDuration = 8 if ($maxDurationValue) { try { $maxDuration = [int]$maxDurationValue } catch { $maxDuration = 8 } } return [PSCustomObject]@{ MaxDuration = $maxDuration RequiresMfa = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('RequiresMfa', 'RequiresMFA')) RequiresJustification = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('RequiresJustification')) RequiresTicket = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('RequiresTicket')) RequiresApproval = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('RequiresApproval')) RequiresAuthenticationContext = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('RequiresAuthenticationContext')) AuthenticationContextId = Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('AuthenticationContextId') AuthenticationContextDisplayName = Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('AuthenticationContextDisplayName') AuthenticationContextDescription = Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('AuthenticationContextDescription') AuthenticationContextDetails = $null PolicyUnavailable = ConvertTo-PIMPolicyCacheBool -Value (Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('PolicyUnavailable')) PIMCacheFetchedAt = Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('FetchedAt', 'PIMCacheFetchedAt') PIMCacheHash = Get-PIMPolicyObjectValue -InputObject $Record -PropertyNames @('Hash', 'PIMCacheHash') PIMCacheSource = 'Disk' } } <# .SYNOPSIS Tests whether a cached policy entry is still fresh. .DESCRIPTION Compares the policy entry timestamp against the configured stale threshold. This is used by the background refresh path to decide which disk-loaded policies should be revalidated after the UI has rendered. .PARAMETER PolicyInfo The policy information object to check. .PARAMETER MaxAgeHours Optional freshness threshold. When omitted or zero, the module-level PIMPolicyCacheStaleAfterHours value is used, with a default of 24 hours. .OUTPUTS System.Boolean. True when the policy timestamp is inside the freshness window. #> function Test-PIMPolicyCacheEntryFresh { [CmdletBinding()] param( [AllowNull()] [object]$PolicyInfo, [int]$MaxAgeHours = 0 ) if ($MaxAgeHours -le 0) { $MaxAgeHours = if ((Get-Variable -Name 'PIMPolicyCacheStaleAfterHours' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyCacheStaleAfterHours) { [int]$script:PIMPolicyCacheStaleAfterHours } else { 24 } } $fetchedAtValue = Get-PIMPolicyObjectValue -InputObject $PolicyInfo -PropertyNames @('PIMCacheFetchedAt', 'FetchedAt', 'CachedAt') if (-not $fetchedAtValue) { return $false } try { $fetchedAt = ConvertTo-PIMPolicyCacheUtcDateTime -Value $fetchedAtValue return (([DateTime]::UtcNow - $fetchedAt).TotalHours -lt $MaxAgeHours) } catch { return $false } } <# .SYNOPSIS Loads the persistent PIM policy cache for the current signed-in scope. .DESCRIPTION Reads sanitized policy metadata from disk and merges it into the module's in-memory cache. The cache file is accepted when its hashed tenant scope matches the current session. Old records beyond the maximum age window are ignored. .PARAMETER Force Reloads the cache even if it was already loaded for the current scope. .PARAMETER MaxAgeDays Maximum persisted record age in days. When omitted or zero, PIMPolicyCacheMaxAgeDays is used, with a default of 30 days. .OUTPUTS PSCustomObject. Includes whether a cache was loaded and how many policies and authentication contexts were imported. #> function Import-PIMPolicyCache { [CmdletBinding()] param( [switch]$Force, [int]$MaxAgeDays = 0 ) if ($MaxAgeDays -le 0) { $MaxAgeDays = if ((Get-Variable -Name 'PIMPolicyCacheMaxAgeDays' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyCacheMaxAgeDays) { [int]$script:PIMPolicyCacheMaxAgeDays } else { 30 } } $scope = Get-PIMPolicyCacheScope if (-not $Force -and (Get-Variable -Name 'PIMPolicyCacheLoadedForScope' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyCacheLoadedForScope -eq $scope.ScopeHash) { return [PSCustomObject]@{ Loaded = $false; PolicyCount = 0; AuthenticationContextCount = 0; Reason = 'AlreadyLoaded' } } $cacheFile = Get-PIMPolicyCacheFilePath if (-not (Test-Path -Path $cacheFile)) { $script:PIMPolicyCacheLoadedForScope = $scope.ScopeHash return [PSCustomObject]@{ Loaded = $false; PolicyCount = 0; AuthenticationContextCount = 0; Reason = 'Missing' } } try { $cacheData = Get-Content -Path $cacheFile -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 20 -ErrorAction Stop $scopeMatches = $cacheData -and $cacheData.PSObject.Properties['ScopeHash'] -and $cacheData.ScopeHash -eq $scope.ScopeHash $tenantMatches = $cacheData -and $cacheData.PSObject.Properties['TenantHash'] -and $cacheData.TenantHash -eq $scope.TenantHash if (-not $scopeMatches -and -not $tenantMatches) { return [PSCustomObject]@{ Loaded = $false; PolicyCount = 0; AuthenticationContextCount = 0; Reason = 'ScopeMismatch' } } if (-not (Get-Variable -Name 'PolicyCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:PolicyCache) { $script:PolicyCache = @{} } if (-not (Get-Variable -Name 'AuthenticationContextCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:AuthenticationContextCache) { $script:AuthenticationContextCache = @{} } $cutoff = [DateTime]::UtcNow.AddDays(-1 * $MaxAgeDays) $policyCount = 0 $contextCount = 0 if ($cacheData.PSObject.Properties['Policies'] -and $cacheData.Policies) { foreach ($policyProperty in $cacheData.Policies.PSObject.Properties) { $record = $policyProperty.Value $fetchedAtValue = Get-PIMPolicyObjectValue -InputObject $record -PropertyNames @('FetchedAt') if ($fetchedAtValue) { try { if ((ConvertTo-PIMPolicyCacheUtcDateTime -Value $fetchedAtValue) -lt $cutoff) { continue } } catch { continue } } $script:PolicyCache[$policyProperty.Name] = ConvertFrom-PIMPolicyCacheRecord -Record $record $policyCount++ } } $script:PIMPolicyCacheLoadedForScope = $scope.ScopeHash Write-Verbose "Loaded persistent PIM policy cache: $policyCount policies" return [PSCustomObject]@{ Loaded = $true; PolicyCount = $policyCount; AuthenticationContextCount = $contextCount; Reason = 'Loaded' } } catch { Write-Verbose "Failed to load persistent PIM policy cache: $($_.Exception.Message)" return [PSCustomObject]@{ Loaded = $false; PolicyCount = 0; AuthenticationContextCount = 0; Reason = 'Error'; Error = $_.Exception.Message } } } <# .SYNOPSIS Saves sanitized PIM policy metadata for the current tenant scope. .DESCRIPTION Persists the in-memory policy cache to the scoped local policy-cache file. Only policy requirement metadata is written. Access tokens, refresh tokens, auth-context tokens, activation request bodies, justifications, and ticket values are never included. .OUTPUTS PSCustomObject. Includes save status, counts, and the cache path when successful. #> function Save-PIMPolicyCache { [CmdletBinding()] param() try { if (-not (Get-Variable -Name 'PolicyCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:PolicyCache) { return $null } if (-not (Get-Variable -Name 'AuthenticationContextCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:AuthenticationContextCache) { $script:AuthenticationContextCache = @{} } $scope = Get-PIMPolicyCacheScope $cacheFile = Get-PIMPolicyCacheFilePath -Create $policies = [ordered]@{} $contexts = [ordered]@{} foreach ($cacheKey in @($script:PolicyCache.Keys | Sort-Object)) { $policyInfo = $script:PolicyCache[$cacheKey] if ($null -eq $policyInfo) { continue } $record = ConvertTo-PIMPolicyCacheRecord -CacheKey $cacheKey -PolicyInfo $policyInfo $policies[$cacheKey] = $record try { $policyInfo | Add-Member -NotePropertyName PIMCacheFetchedAt -NotePropertyValue $record.FetchedAt -Force $policyInfo | Add-Member -NotePropertyName PIMCacheHash -NotePropertyValue $record.Hash -Force } catch { } } $cacheData = [ordered]@{ SchemaVersion = 1 ScopeType = $scope.ScopeType ScopeHash = $scope.ScopeHash TenantHash = $scope.TenantHash SavedAt = [DateTime]::UtcNow.ToString('o') StaleAfterHours = if ((Get-Variable -Name 'PIMPolicyCacheStaleAfterHours' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyCacheStaleAfterHours) { [int]$script:PIMPolicyCacheStaleAfterHours } else { 24 } Policies = $policies AuthenticationContexts = $contexts } $temporaryFile = "$cacheFile.tmp" $cacheData | ConvertTo-Json -Depth 20 | Set-Content -Path $temporaryFile -Encoding UTF8 -Force Move-Item -Path $temporaryFile -Destination $cacheFile -Force Write-Verbose "Saved persistent PIM policy cache: $($policies.Count) policies" return [PSCustomObject]@{ Saved = $true; PolicyCount = $policies.Count; AuthenticationContextCount = $contexts.Count; Path = $cacheFile } } catch { Write-Verbose "Failed to save persistent PIM policy cache: $($_.Exception.Message)" return [PSCustomObject]@{ Saved = $false; Error = $_.Exception.Message } } } <# .SYNOPSIS Creates a lightweight role snapshot for background policy refresh. .DESCRIPTION Copies only the role identity fields needed to refresh policy metadata. The snapshot avoids passing UI controls, Graph model objects, or activation-time inputs into the background job. .PARAMETER Role The role object to snapshot. .OUTPUTS PSCustomObject. A minimal role identity object. #> function New-PIMPolicyRoleSnapshot { [CmdletBinding()] param( [Parameter(Mandatory)] [object]$Role ) return [PSCustomObject]@{ Type = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('Type') DisplayName = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('DisplayName', 'Name') Id = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('Id') RoleDefinitionId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('RoleDefinitionId') GroupId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('GroupId') ResourceId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('ResourceId') FullScope = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('FullScope') Scope = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('Scope') SubscriptionId = Get-PIMPolicyObjectValue -InputObject $Role -PropertyNames @('SubscriptionId') } } <# .SYNOPSIS Refreshes policy-cache entries for a set of roles. .DESCRIPTION Fetches fresh policies for Entra, PIM group, and Azure Resource roles, updates the in-memory policy cache, and persists the sanitized cache back to disk. The helper can force refresh by removing existing in-memory entries before fetching. .PARAMETER Roles The role objects or role snapshots whose policy metadata should be refreshed. .PARAMETER ForceRefresh Removes matching in-memory entries before fetching, ensuring the source services are queried again. .PARAMETER DisableParallelProcessing Disables parallel policy fetching for paths that support it. .PARAMETER ThrottleLimit Maximum concurrency used by parallel policy fetch paths. .OUTPUTS PSCustomObject. Counts refreshed policies and reports whether the cache was saved. #> function Update-PIMPolicyCacheForRoles { [CmdletBinding()] param( [object[]]$Roles = @(), [switch]$ForceRefresh, [switch]$DisableParallelProcessing, [int]$ThrottleLimit = 10 ) if (-not $Roles) { return [PSCustomObject]@{ PoliciesUpdated = 0; AuthenticationContextsUpdated = 0 } } if (-not (Get-Variable -Name 'PolicyCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:PolicyCache) { $script:PolicyCache = @{} } if (-not (Get-Variable -Name 'AuthenticationContextCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:AuthenticationContextCache) { $script:AuthenticationContextCache = @{} } $policyResult = @{} $entraRoleIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $groupIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $azureRoles = [System.Collections.ArrayList]::new() $seenAzureKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($role in @($Roles)) { if ($null -eq $role) { continue } $cacheKey = Get-PIMPolicyCacheKey -Role $role if ($ForceRefresh -and $cacheKey -and $script:PolicyCache.ContainsKey($cacheKey)) { $script:PolicyCache.Remove($cacheKey) } $roleType = [string](Get-PIMPolicyObjectValue -InputObject $role -PropertyNames @('Type')) if ($roleType -eq 'Azure') { $roleType = 'AzureResource' } switch ($roleType) { 'Entra' { $roleId = Get-PIMPolicyObjectValue -InputObject $role -PropertyNames @('RoleDefinitionId', 'Id') if ($roleId) { [void]$entraRoleIds.Add([string]$roleId) } } 'Group' { $groupId = Get-PIMPolicyObjectValue -InputObject $role -PropertyNames @('GroupId', 'ResourceId', 'Id') if ($groupId) { [void]$groupIds.Add([string]$groupId) } } 'AzureResource' { if ($cacheKey -and $seenAzureKeys.Add($cacheKey)) { [void]$azureRoles.Add((New-PIMPolicyRoleSnapshot -Role $role)) } } } } if ($entraRoleIds.Count -gt 0) { Get-PIMPoliciesBatch -RoleIds @($entraRoleIds) -Type 'Entra' -PolicyCache $policyResult -DisableParallelProcessing:$DisableParallelProcessing -ThrottleLimit $ThrottleLimit } if ($groupIds.Count -gt 0) { Get-PIMPoliciesBatch -GroupIds @($groupIds) -Type 'Group' -PolicyCache $policyResult -DisableParallelProcessing:$DisableParallelProcessing -ThrottleLimit $ThrottleLimit } foreach ($azureRole in @($azureRoles)) { $cacheKey = Get-PIMPolicyCacheKey -Role $azureRole if (-not $cacheKey) { continue } try { $roleDefinitionId = Get-PIMPolicyObjectValue -InputObject $azureRole -PropertyNames @('RoleDefinitionId', 'Id') $subscriptionId = Get-PIMPolicyObjectValue -InputObject $azureRole -PropertyNames @('SubscriptionId') $scope = Get-PIMPolicyObjectValue -InputObject $azureRole -PropertyNames @('FullScope', 'Scope') if (-not $roleDefinitionId) { continue } $azurePolicy = Get-AzureResourcePIMPolicy -RoleDefinitionId $roleDefinitionId -SubscriptionId $subscriptionId -Scope $scope if ($azurePolicy) { $policyResult[$cacheKey] = $azurePolicy $script:PolicyCache[$cacheKey] = $azurePolicy } } catch { Write-Verbose "Failed to refresh Azure policy cache for ${cacheKey}: $($_.Exception.Message)" } } foreach ($cacheKey in @($policyResult.Keys)) { $script:PolicyCache[$cacheKey] = $policyResult[$cacheKey] } $saveResult = Save-PIMPolicyCache return [PSCustomObject]@{ PoliciesUpdated = $policyResult.Count AuthenticationContextsUpdated = 0 Saved = if ($saveResult) { $saveResult.Saved } else { $false } } } <# .SYNOPSIS Updates eligible-role UI columns from the policy cache. .DESCRIPTION Re-applies policy requirements from $script:PolicyCache to the eligible roles ListView. This lets the UI update after a background refresh completes without rebuilding the entire form or role list. .PARAMETER Form The main PIM Activation Windows Forms form containing the lstEligible control. .OUTPUTS None. #> function Update-PIMPolicyColumnsFromCache { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Windows.Forms.Form]$Form ) if (-not $Form -or $Form.IsDisposed) { return } if (-not (Get-Variable -Name 'PolicyCache' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:PolicyCache -or $script:PolicyCache.Count -eq 0) { return } $eligibleMatches = $Form.Controls.Find('lstEligible', $true) if (-not $eligibleMatches -or $eligibleMatches.Count -eq 0) { return } $eligibleListView = $eligibleMatches[0] if (-not $eligibleListView -or $eligibleListView.IsDisposed) { return } $eligibleListView.BeginUpdate() try { foreach ($item in $eligibleListView.Items) { if (-not $item -or -not $item.Tag) { continue } $role = $item.Tag $cacheKey = Get-PIMPolicyCacheKey -Role $role if (-not $cacheKey -or -not $script:PolicyCache.ContainsKey($cacheKey)) { continue } $policyInfo = $script:PolicyCache[$cacheKey] try { $role | Add-Member -NotePropertyName PolicyInfo -NotePropertyValue $policyInfo -Force } catch { } $item.Tag = $role $policyUnavailable = $policyInfo -and $policyInfo.PSObject.Properties['PolicyUnavailable'] -and $policyInfo.PolicyUnavailable if ($item.SubItems.Count -gt 4) { $item.SubItems[4].Text = if ($policyUnavailable) { 'N/A' } elseif ($policyInfo -and $policyInfo.MaxDuration) { "$($policyInfo.MaxDuration)h" } else { '8h' } } if ($item.SubItems.Count -gt 5) { $item.SubItems[5].Text = if ($policyUnavailable) { 'N/A' } elseif ($policyInfo -and $policyInfo.RequiresMfa) { 'Yes' } else { 'No' } } if ($item.SubItems.Count -gt 6) { $item.SubItems[6].Text = if ($policyUnavailable) { 'N/A' } elseif ($policyInfo -and $policyInfo.RequiresAuthenticationContext) { $contextId = if ($policyInfo.PSObject.Properties['AuthenticationContextId']) { $policyInfo.AuthenticationContextId } else { $null } if ($contextId) { "Required ($contextId)" } else { 'Required' } } else { 'No' } } if ($item.SubItems.Count -gt 7) { $item.SubItems[7].Text = if ($policyUnavailable) { 'N/A' } elseif ($policyInfo -and $policyInfo.RequiresJustification) { 'Required' } else { 'No' } } if ($item.SubItems.Count -gt 8) { $item.SubItems[8].Text = if ($policyUnavailable) { 'N/A' } elseif ($policyInfo -and $policyInfo.RequiresTicket) { 'Yes' } else { 'No' } } if ($item.SubItems.Count -gt 9) { $item.SubItems[9].Text = if ($policyUnavailable) { 'N/A' } elseif ($policyInfo -and $policyInfo.RequiresApproval) { 'Required' } else { 'No' } } if ($policyInfo) { if ($policyInfo.RequiresApproval) { $item.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 212) } elseif ($policyInfo.RequiresAuthenticationContext) { $item.ForeColor = [System.Drawing.Color]::FromArgb(0, 78, 146) } else { $item.ForeColor = [System.Drawing.Color]::FromArgb(32, 31, 30) } } } } finally { $eligibleListView.EndUpdate() } } <# .SYNOPSIS Starts a background refresh for stale disk-loaded policy-cache entries. .DESCRIPTION Identifies cached role policies that are present but stale, starts a thread job to refresh those policy entries, saves the updated sanitized cache, and uses a Windows Forms timer to merge refreshed metadata back into the current UI when the job finishes. Fresh entries are skipped so startup remains fast. .PARAMETER Form The main PIM Activation Windows Forms form to update after the background refresh completes. .PARAMETER DisableParallelProcessing Disables parallel policy fetching in the background refresh job. .PARAMETER ThrottleLimit Maximum concurrency used by supported background policy fetch paths. .OUTPUTS None. .NOTES The background job imports the module in a separate thread and relies on the current Graph/Azure session being available to the process. If that context is unavailable, the refresh fails quietly and the already loaded cache remains a startup performance hint only. #> function Start-PIMPolicyCacheBackgroundRefresh { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Windows.Forms.Form]$Form, [switch]$DisableParallelProcessing, [int]$ThrottleLimit = 10 ) try { if (-not $Form -or $Form.IsDisposed) { return } if (-not (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)) { Write-Verbose 'Start-ThreadJob is unavailable; background policy cache refresh skipped.' return } if ((Get-Variable -Name 'PIMPolicyBackgroundRefreshJob' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyBackgroundRefreshJob) { if ($script:PIMPolicyBackgroundRefreshJob.State -in @('NotStarted', 'Running')) { return } } $roleCandidates = @() if ((Get-Variable -Name 'CachedEligibleRoles' -Scope Script -ErrorAction SilentlyContinue) -and $script:CachedEligibleRoles) { $roleCandidates += @($script:CachedEligibleRoles) } if ((Get-Variable -Name 'CachedActiveRoles' -Scope Script -ErrorAction SilentlyContinue) -and $script:CachedActiveRoles) { $roleCandidates += @($script:CachedActiveRoles) } if ($roleCandidates.Count -eq 0) { return } $refreshSnapshots = [System.Collections.ArrayList]::new() $seenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($role in $roleCandidates) { if ($null -eq $role) { continue } $cacheKey = Get-PIMPolicyCacheKey -Role $role if (-not $cacheKey -or -not $seenKeys.Add($cacheKey)) { continue } if (-not $script:PolicyCache.ContainsKey($cacheKey)) { continue } if (Test-PIMPolicyCacheEntryFresh -PolicyInfo $script:PolicyCache[$cacheKey]) { continue } [void]$refreshSnapshots.Add((New-PIMPolicyRoleSnapshot -Role $role)) } if ($refreshSnapshots.Count -eq 0) { return } $modulePath = Join-Path $script:ModuleRoot 'PIMActivation.psd1' $scope = Get-PIMPolicyCacheScope $currentUserId = if ((Get-Variable -Name 'CurrentUser' -Scope Script -ErrorAction SilentlyContinue) -and $script:CurrentUser -and $script:CurrentUser.PSObject.Properties['Id']) { [string]$script:CurrentUser.Id } else { $null } $currentUserPrincipalName = if ((Get-Variable -Name 'CurrentUser' -Scope Script -ErrorAction SilentlyContinue) -and $script:CurrentUser -and $script:CurrentUser.PSObject.Properties['UserPrincipalName']) { [string]$script:CurrentUser.UserPrincipalName } elseif ((Get-Variable -Name 'CurrentGraphUser' -Scope Script -ErrorAction SilentlyContinue) -and -not [string]::IsNullOrWhiteSpace([string]$script:CurrentGraphUser)) { [string]$script:CurrentGraphUser } elseif ((Get-Variable -Name 'GraphContext' -Scope Script -ErrorAction SilentlyContinue) -and $script:GraphContext -and $script:GraphContext.PSObject.Properties['Account']) { [string]$script:GraphContext.Account } else { $null } $disableParallel = [bool]$DisableParallelProcessing Write-Verbose "Starting background PIM policy cache refresh for $($refreshSnapshots.Count) stale policies" $job = Start-ThreadJob -Name 'PIMPolicyCacheRefresh' -ArgumentList @($modulePath, @($refreshSnapshots), $scope.TenantId, $currentUserId, $currentUserPrincipalName, $disableParallel, $ThrottleLimit) -ScriptBlock { param( [string]$ModulePath, [object[]]$RoleSnapshots, [string]$TenantId, [string]$CurrentUserId, [string]$CurrentUserPrincipalName, [bool]$DisableParallel, [int]$Throttle ) $VerbosePreference = 'SilentlyContinue' try { Import-Module $ModulePath -Force -ErrorAction Stop & (Get-Module PIMActivation) { param( [object[]]$InnerRoleSnapshots, [string]$InnerTenantId, [string]$InnerCurrentUserId, [string]$InnerCurrentUserPrincipalName, [bool]$InnerDisableParallel, [int]$InnerThrottle ) $script:CurrentTenantId = $InnerTenantId $script:CurrentUser = [PSCustomObject]@{ Id = $InnerCurrentUserId UserPrincipalName = $InnerCurrentUserPrincipalName } $script:GraphContext = [PSCustomObject]@{ TenantId = $InnerTenantId Account = $InnerCurrentUserPrincipalName } Import-PIMPolicyCache -Force | Out-Null $refreshResult = Update-PIMPolicyCacheForRoles -Roles $InnerRoleSnapshots -ForceRefresh -DisableParallelProcessing:$InnerDisableParallel -ThrottleLimit $InnerThrottle return [PSCustomObject]@{ Success = $true PoliciesUpdated = $refreshResult.PoliciesUpdated AuthenticationContextsUpdated = $refreshResult.AuthenticationContextsUpdated } } @($RoleSnapshots) $TenantId $CurrentUserId $CurrentUserPrincipalName $DisableParallel $Throttle } catch { return [PSCustomObject]@{ Success = $false Error = $_.Exception.Message } } } $script:PIMPolicyBackgroundRefreshJob = $job $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 1500 $timer.Add_Tick({ try { if (-not (Get-Variable -Name 'PIMPolicyBackgroundRefreshJob' -Scope Script -ErrorAction SilentlyContinue) -or -not $script:PIMPolicyBackgroundRefreshJob) { $this.Stop() $this.Dispose() return } $activeJob = $script:PIMPolicyBackgroundRefreshJob if ($activeJob.State -notin @('Completed', 'Failed', 'Stopped')) { return } $this.Stop() $jobOutput = Receive-Job -Job $activeJob -ErrorAction SilentlyContinue Remove-Job -Job $activeJob -Force -ErrorAction SilentlyContinue $script:PIMPolicyBackgroundRefreshJob = $null $successfulOutput = @($jobOutput | Where-Object { $_ -and $_.PSObject.Properties['Success'] -and $_.Success } | Select-Object -First 1) if ($successfulOutput.Count -gt 0) { Import-PIMPolicyCache -Force | Out-Null Update-PIMPolicyColumnsFromCache -Form $Form Write-Verbose "Background PIM policy cache refresh completed. Policies updated: $($successfulOutput[0].PoliciesUpdated)" } else { $errorOutput = @($jobOutput | Where-Object { $_ -and $_.PSObject.Properties['Error'] } | Select-Object -First 1) if ($errorOutput.Count -gt 0) { Write-Verbose "Background PIM policy cache refresh skipped or failed: $($errorOutput[0].Error)" } } $this.Dispose() } catch { Write-Verbose "Failed while completing background policy cache refresh: $($_.Exception.Message)" try { $this.Stop(); $this.Dispose() } catch { } } }) $script:PIMPolicyBackgroundRefreshTimer = $timer $Form.Add_FormClosed({ try { if ((Get-Variable -Name 'PIMPolicyBackgroundRefreshTimer' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyBackgroundRefreshTimer) { $script:PIMPolicyBackgroundRefreshTimer.Stop() $script:PIMPolicyBackgroundRefreshTimer.Dispose() $script:PIMPolicyBackgroundRefreshTimer = $null } if ((Get-Variable -Name 'PIMPolicyBackgroundRefreshJob' -Scope Script -ErrorAction SilentlyContinue) -and $script:PIMPolicyBackgroundRefreshJob -and $script:PIMPolicyBackgroundRefreshJob.State -in @('NotStarted', 'Running')) { Stop-Job -Job $script:PIMPolicyBackgroundRefreshJob -ErrorAction SilentlyContinue Remove-Job -Job $script:PIMPolicyBackgroundRefreshJob -Force -ErrorAction SilentlyContinue $script:PIMPolicyBackgroundRefreshJob = $null } } catch { } }) $timer.Start() } catch { Write-Verbose "Unable to start background PIM policy cache refresh: $($_.Exception.Message)" } } |