Private/Invoke-IdleContextResolvers.ps1
|
Set-StrictMode -Version Latest function Invoke-IdleContextResolvers { <# .SYNOPSIS Executes ContextResolvers during plan building to populate Request.Context. .DESCRIPTION Runs each configured resolver in declared order, invoking the appropriate provider capability and writing the result under Request.Context using a provider/auth-scoped namespace as the source of truth, with engine-defined Views for common aggregation patterns. Rules enforced: - Only capabilities in the read-only allow-list (Get-IdleReadOnlyCapabilities) may be used. - Results are written to the provider/auth-scoped path: Request.Context.Providers.<ProviderAlias>.<AuthSessionKey>.<CapabilitySubPath> where AuthSessionKey is 'Default' when With.AuthSessionName is not specified. - Engine-defined Views are (re)built deterministically after each resolver: Request.Context.Views.<CapabilitySubPath> (global: all providers/sessions) Request.Context.Views.Providers.<ProviderAlias>.<...> (provider: all sessions) Request.Context.Views.Sessions.<AuthSessionKey>.<...> (session: all providers) Request.Context.Views.Providers.<ProviderAlias>.Sessions.<AuthSessionKey>.<...> (exact) View semantics are capability-specific; both IdLE.Entitlement.List and IdLE.Identity.Read have views. - For IdLE.Entitlement.List, each entry is annotated with SourceProvider and SourceAuthSessionName to enable auditing and source-specific filtering. - For IdLE.Identity.Read, the profile object is annotated with SourceProvider and SourceAuthSessionName; multi-source views use last-write-wins with deterministic sort order. - Provider alias and AuthSessionKey must be valid context path segments. - Provider is selected by alias when 'With.Provider' is specified. When 'With.Provider' is omitted, auto-selection only succeeds if exactly one provider advertises the capability; zero matches or multiple matches both cause a fail-fast error. - Auth sessions are supported via With.AuthSessionName / With.AuthSessionOptions, using the AuthSessionBroker in Providers (same pattern as step execution). This function mutates Request.Context in place so that subsequent condition evaluation can reference the resolved data via scoped paths or Views. .PARAMETER Resolvers Array of resolver hashtables from the workflow definition. May be null or empty. .PARAMETER Providers Provider map passed to the plan (same format as -Providers on New-IdlePlanObject). May contain an AuthSessionBroker entry for auth session acquisition. .PARAMETER Request The lifecycle request object. Request.Context is mutated in place. #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object[]] $Resolvers, [Parameter()] [AllowNull()] [object] $Providers, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Request ) if ($null -eq $Resolvers -or @($Resolvers).Count -eq 0) { return } $readOnlyCapabilities = @(Get-IdleReadOnlyCapabilities) $i = 0 foreach ($resolver in @($Resolvers)) { $resolverPath = "ContextResolvers[$i]" if ($null -eq $resolver -or -not ($resolver -is [System.Collections.IDictionary])) { throw [System.ArgumentException]::new("$resolverPath must be a hashtable.", 'Workflow') } # --- Capability --- if (-not $resolver.Contains('Capability') -or [string]::IsNullOrWhiteSpace([string]$resolver.Capability)) { throw [System.ArgumentException]::new("$resolverPath is missing required key 'Capability'.", 'Workflow') } $capability = [string]$resolver.Capability if ($readOnlyCapabilities -notcontains $capability) { $allowedList = $readOnlyCapabilities -join ', ' throw [System.ArgumentException]::new( "ContextResolver capability '$capability' is not in the read-only allow-list. Allowed capabilities: $allowedList.", 'Workflow' ) } # --- With (optional, template-resolved) --- $with = if ($resolver.Contains('With') -and $null -ne $resolver.With) { Copy-IdleDataObject -Value $resolver.With } else { @{} } if ($with -isnot [System.Collections.IDictionary]) { throw [System.ArgumentException]::new("$resolverPath 'With' must be a hashtable.", 'Workflow') } # Resolve template placeholders in With values (e.g., {{Request.IdentityKeys.Id}}). $with = Resolve-IdleWorkflowTemplates -Value $with -Request $Request -StepName $resolverPath # --- Provider selection --- $providerAlias = if ($with -is [System.Collections.IDictionary] -and $with.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$with.Provider)) { [string]$with.Provider } else { $null } $resolvedProviderAlias = Select-IdleResolverProviderAlias -Capability $capability -ProviderAlias $providerAlias -Providers $Providers -ResolverPath $resolverPath # --- Validate provider alias as a context path segment --- Assert-IdleContextPathSegment -Value $resolvedProviderAlias -Label 'Provider alias' -ResolverPath $resolverPath # --- Auth session (optional) --- # Supports With.AuthSessionName + With.AuthSessionOptions using the same pattern as steps. $authSession = $null $authBroker = Get-IdleAuthSessionBroker -Providers $Providers $authSessionKey = 'Default' if ($with -is [System.Collections.IDictionary] -and $with.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$with.AuthSessionName)) { $sessionName = [string]$with.AuthSessionName $authSessionKey = $sessionName $sessionOptions = if ($with.Contains('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } if ($null -ne $sessionOptions -and $sessionOptions -isnot [hashtable]) { throw [System.ArgumentException]::new("$resolverPath 'With.AuthSessionOptions' must be a hashtable.", 'Workflow') } # --- Validate auth session key as a context path segment --- Assert-IdleContextPathSegment -Value $authSessionKey -Label 'AuthSessionName' -ResolverPath $resolverPath if ($null -eq $authBroker) { throw [System.ArgumentException]::new( "$resolverPath specifies With.AuthSessionName '$sessionName' but no AuthSessionBroker was found in Providers.", 'Providers' ) } $authSession = $authBroker.AcquireAuthSession($sessionName, $sessionOptions) } elseif ($null -ne $authBroker) { # No explicit session name - try default acquisition for providers that require auth try { $authSession = $authBroker.AcquireAuthSession('', $null) } catch { $authSession = $null } } # --- Dispatch --- # Wrap in try/catch to ensure provider exceptions are always terminating and include # resolver context in the error message, rather than silently continuing with a null # result that later causes confusing template resolution failures. $result = $null try { $result = Invoke-IdleResolverCapabilityDispatch ` -Capability $capability ` -ProviderAlias $resolvedProviderAlias ` -Providers $Providers ` -With $with ` -AuthSession $authSession ` -ResolverPath $resolverPath } catch { throw [System.InvalidOperationException]::new( "${resolverPath}: Provider '$resolvedProviderAlias' failed while resolving capability '$capability'. $($_.Exception.Message)", $_.Exception ) } # --- Annotate entitlement results with source metadata --- if ($capability -eq 'IdLE.Entitlement.List') { $result = @(Add-IdleEntitlementSourceMetadata -Entitlements @($result) -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey) } # --- Annotate profile result with source metadata --- if ($capability -eq 'IdLE.Identity.Read') { $result = Add-IdleProfileSourceMetadata -Profile $result -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey } # --- Write to provider/auth-scoped path (source of truth) --- # Path: Providers.<ProviderAlias>.<AuthSessionKey>.<CapabilitySubPath> $contextSubPath = Get-IdleCapabilityContextPath -Capability $capability $scopedPath = "Providers.$resolvedProviderAlias.$authSessionKey.$contextSubPath" Set-IdleContextValue -Context $Request.Context -Path $scopedPath -Value $result # --- Rebuild deterministic Views for capabilities with defined view semantics --- Build-IdleContextResolverViews -Context $Request.Context -Capability $capability -CapabilitySubPath $contextSubPath $i++ } } function Assert-IdleContextPathSegment { <# .SYNOPSIS Validates that a value is a valid context path segment (no dots, valid identifier characters). .DESCRIPTION Context path segments are used to build hierarchical paths in Request.Context. They must not contain dots (path separators) and must match a safe identifier pattern. .PARAMETER Value The value to validate. .PARAMETER Label Human-readable label for error messages (e.g., 'Provider alias', 'AuthSessionName'). .PARAMETER ResolverPath The resolver path (e.g., 'ContextResolvers[0]') for error context. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Value, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Label, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ResolverPath ) if ($Value -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$') { throw [System.ArgumentException]::new( ('{0}: {1} ''{2}'' is not a valid context path segment. Must start with alphanumeric, followed by alphanumeric, hyphens, or underscores (max 64 chars total, no dots allowed).' -f $ResolverPath, $Label, $Value), 'Workflow' ) } } function Add-IdleEntitlementSourceMetadata { <# .SYNOPSIS Annotates each entitlement entry with SourceProvider and SourceAuthSessionName metadata. .DESCRIPTION Ensures every entitlement returned by IdLE.Entitlement.List resolvers carries source information to support auditing, per-provider filtering, and merged view semantics. .PARAMETER Entitlements Array of entitlement objects (hashtables or PSCustomObjects). .PARAMETER SourceProvider The provider alias that produced these entitlements. .PARAMETER SourceAuthSessionName The auth session key used ('Default' if no explicit session was specified). .OUTPUTS Object[] #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter()] [AllowNull()] [AllowEmptyCollection()] [object[]] $Entitlements, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SourceProvider, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SourceAuthSessionName ) if ($null -eq $Entitlements -or $Entitlements.Count -eq 0) { return @() } $result = [System.Collections.Generic.List[object]]::new() foreach ($item in $Entitlements) { if ($null -eq $item) { # Skip null entries; provider implementations must not return null items in an entitlement list. continue } if ($item -is [System.Collections.IDictionary]) { $enriched = @{} foreach ($key in $item.Keys) { $enriched[$key] = $item[$key] } $enriched['SourceProvider'] = $SourceProvider $enriched['SourceAuthSessionName'] = $SourceAuthSessionName $result.Add($enriched) } else { # PSCustomObject or other reference type — add properties non-destructively $item | Add-Member -MemberType NoteProperty -Name 'SourceProvider' -Value $SourceProvider -Force $item | Add-Member -MemberType NoteProperty -Name 'SourceAuthSessionName' -Value $SourceAuthSessionName -Force $result.Add($item) } } return @($result) } function Add-IdleProfileSourceMetadata { <# .SYNOPSIS Annotates a profile object with SourceProvider and SourceAuthSessionName metadata. .DESCRIPTION Ensures every profile returned by IdLE.Identity.Read resolvers carries source information to support auditing and view semantics (including identifying which provider/session a profile came from in aggregated views). .PARAMETER Profile The profile object (hashtable or PSCustomObject) to annotate. .PARAMETER SourceProvider The provider alias that produced the profile. .PARAMETER SourceAuthSessionName The auth session key used ('Default' if no explicit session was specified). .OUTPUTS Object #> [CmdletBinding()] [OutputType([object])] param( [Parameter()] [AllowNull()] [object] $Profile, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SourceProvider, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $SourceAuthSessionName ) if ($null -eq $Profile) { return $null } if ($Profile -is [System.Collections.IDictionary]) { $enriched = @{} foreach ($key in $Profile.Keys) { $enriched[$key] = $Profile[$key] } $enriched['SourceProvider'] = $SourceProvider $enriched['SourceAuthSessionName'] = $SourceAuthSessionName return $enriched } else { # PSCustomObject or other reference type — add properties non-destructively $Profile | Add-Member -MemberType NoteProperty -Name 'SourceProvider' -Value $SourceProvider -Force $Profile | Add-Member -MemberType NoteProperty -Name 'SourceAuthSessionName' -Value $SourceAuthSessionName -Force return $Profile } } function Build-IdleContextResolverViews { <# .SYNOPSIS Rebuilds engine-defined Views in Request.Context for capabilities with defined view semantics. .DESCRIPTION Views are deterministic, engine-defined aggregations of scoped resolver outputs. Called after each resolver execution to keep views current. IdLE.Entitlement.List view semantics (list merge, all entries are preserved): - Global view (all providers, all sessions): Request.Context.Views.Identity.Entitlements - Provider view (one provider, all sessions): Request.Context.Views.Providers.<ProviderAlias>.Identity.Entitlements - Session view (all providers, one session): Request.Context.Views.Sessions.<AuthSessionKey>.Identity.Entitlements - Provider+Session view (one provider, one session): Request.Context.Views.Providers.<ProviderAlias>.Sessions.<AuthSessionKey>.Identity.Entitlements IdLE.Identity.Read view semantics (single object, last writer wins, deterministic sort order): - Global view (all providers, all sessions): Request.Context.Views.Identity.Profile - Provider view (one provider, all sessions): Request.Context.Views.Providers.<ProviderAlias>.Identity.Profile - Session view (all providers, one session): Request.Context.Views.Sessions.<AuthSessionKey>.Identity.Profile - Provider+Session view (one provider, one session): Request.Context.Views.Providers.<ProviderAlias>.Sessions.<AuthSessionKey>.Identity.Profile All views use stable ordering (sorted by ProviderAlias then AuthSessionKey). For IdLE.Identity.Read, views where multiple profiles could contribute use last-write-wins with deterministic sort order (last provider or session alphabetically wins). .PARAMETER Context The Request.Context hashtable to update. .PARAMETER Capability The capability identifier (e.g., 'IdLE.Entitlement.List'). .PARAMETER CapabilitySubPath The capability sub-path (e.g., 'Identity.Entitlements'). #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [System.Collections.IDictionary] $Context, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Capability, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $CapabilitySubPath ) # Only capabilities with defined view semantics are processed. if ($Capability -notin @('IdLE.Entitlement.List', 'IdLE.Identity.Read')) { return } $providersNode = if ($Context.Contains('Providers')) { $Context['Providers'] } else { $null } if ($null -eq $providersNode -or -not ($providersNode -is [System.Collections.IDictionary])) { return } if ($Capability -eq 'IdLE.Entitlement.List') { $globalList = [System.Collections.Generic.List[object]]::new() $perProviderLists = @{} $perSessionLists = @{} $perProviderSessionLists = @{} # Stable ordering: sorted by ProviderAlias, then AuthSessionKey $sortedProviders = @($providersNode.Keys | Sort-Object) foreach ($providerAlias in $sortedProviders) { $providerNode = $providersNode[$providerAlias] if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } # Always initialize tracking for this provider so stale views are overwritten even when empty. if (-not $perProviderLists.Contains($providerAlias)) { $perProviderLists[$providerAlias] = [System.Collections.Generic.List[object]]::new() } if (-not $perProviderSessionLists.Contains($providerAlias)) { $perProviderSessionLists[$providerAlias] = @{} } $sortedAuthKeys = @($providerNode.Keys | Sort-Object) foreach ($authKey in $sortedAuthKeys) { $authNode = $providerNode[$authKey] if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } # Always initialize tracking for this session so stale views are overwritten even when empty. if (-not $perSessionLists.Contains($authKey)) { $perSessionLists[$authKey] = [System.Collections.Generic.List[object]]::new() } if (-not $perProviderSessionLists[$providerAlias].Contains($authKey)) { $perProviderSessionLists[$providerAlias][$authKey] = [System.Collections.Generic.List[object]]::new() } # Navigate the CapabilitySubPath within the auth node; null means not yet populated. $items = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath if ($null -eq $items) { continue } foreach ($item in @($items)) { $globalList.Add($item) $perProviderLists[$providerAlias].Add($item) $perSessionLists[$authKey].Add($item) $perProviderSessionLists[$providerAlias][$authKey].Add($item) } } } # Always write all views (including empty arrays) to ensure stale data from prior runs is cleared. Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) foreach ($providerAlias in ($perProviderLists.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) } foreach ($authKey in ($perSessionLists.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Sessions.$authKey.$CapabilitySubPath" -Value @($perSessionLists[$authKey]) } foreach ($pAlias in ($perProviderSessionLists.Keys | Sort-Object)) { foreach ($aKey in ($perProviderSessionLists[$pAlias].Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value @($perProviderSessionLists[$pAlias][$aKey]) } } return } if ($Capability -eq 'IdLE.Identity.Read') { # Profile views use last-non-null-wins with deterministic (sorted) ordering. # When multiple profiles exist for a view scope, the last non-null profile in sort order wins. # All views (including null) are always written to clear stale data from prior runs. $globalProfile = $null $perProviderProfiles = @{} $perSessionProfiles = @{} $perProviderSessionProfiles = @{} # Stable ordering: sorted by ProviderAlias, then AuthSessionKey $sortedProviders = @($providersNode.Keys | Sort-Object) foreach ($providerAlias in $sortedProviders) { $providerNode = $providersNode[$providerAlias] if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } # Always initialize tracking for this provider so stale views are overwritten even when null. if (-not $perProviderProfiles.Contains($providerAlias)) { $perProviderProfiles[$providerAlias] = $null } if (-not $perProviderSessionProfiles.Contains($providerAlias)) { $perProviderSessionProfiles[$providerAlias] = @{} } $sortedAuthKeys = @($providerNode.Keys | Sort-Object) foreach ($authKey in $sortedAuthKeys) { $authNode = $providerNode[$authKey] if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } # Always initialize tracking for this session so stale views are overwritten even when null. if (-not $perSessionProfiles.Contains($authKey)) { $perSessionProfiles[$authKey] = $null } $profile = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath # Aggregated views use last-non-null-wins semantics. if ($null -ne $profile) { $globalProfile = $profile } if ($null -ne $profile) { $perProviderProfiles[$providerAlias] = $profile } if ($null -ne $profile) { $perSessionProfiles[$authKey] = $profile } # Per-provider+session view: always write exact value (even null). $perProviderSessionProfiles[$providerAlias][$authKey] = $profile } } # Always write all views (including null) to ensure stale data from prior runs is cleared. Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value $globalProfile foreach ($pAlias in ($perProviderProfiles.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.$CapabilitySubPath" -Value $perProviderProfiles[$pAlias] } foreach ($aKey in ($perSessionProfiles.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Sessions.$aKey.$CapabilitySubPath" -Value $perSessionProfiles[$aKey] } foreach ($pAlias in ($perProviderSessionProfiles.Keys | Sort-Object)) { foreach ($aKey in ($perProviderSessionProfiles[$pAlias].Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value $perProviderSessionProfiles[$pAlias][$aKey] } } } } function Get-IdleAuthSessionBroker { <# .SYNOPSIS Extracts the AuthSessionBroker from a Providers map (if present). #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Providers ) if ($null -eq $Providers -or -not ($Providers -is [System.Collections.IDictionary])) { return $null } if ($Providers.ContainsKey('AuthSessionBroker')) { return $Providers['AuthSessionBroker'] } return $null } function Select-IdleResolverProviderAlias { <# .SYNOPSIS Selects the provider alias for a context resolver capability. .DESCRIPTION If ProviderAlias is given, validates it exists in Providers and returns it. Otherwise, finds all providers advertising the capability, sorts them by alias for determinism, and returns the alias if exactly one matches. Throws an explicit ambiguity error when multiple providers match. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Capability, [Parameter()] [AllowNull()] [string] $ProviderAlias, [Parameter()] [AllowNull()] [object] $Providers, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ResolverPath ) if (-not [string]::IsNullOrWhiteSpace($ProviderAlias)) { # Explicit provider alias if ($null -eq $Providers -or -not ($Providers -is [System.Collections.IDictionary]) -or -not $Providers.ContainsKey($ProviderAlias)) { throw [System.ArgumentException]::new( "$ResolverPath references provider '$ProviderAlias' but no provider with that alias was found in the Providers map.", 'Providers' ) } return $ProviderAlias } # Auto-select: collect all providers advertising the capability (sorted by alias for determinism) $normalizedCapability = ConvertTo-IdleNormalizedCapability -Capability $Capability $matchingAliases = [System.Collections.Generic.List[string]]::new() if ($null -ne $Providers -and $Providers -is [System.Collections.IDictionary]) { $sortedAliases = @($Providers.Keys | Sort-Object) foreach ($alias in $sortedAliases) { $p = $Providers[$alias] if ($null -eq $p) { continue } if (-not ($p -is [psobject])) { continue } if (-not ($p.PSObject.Methods.Name -contains 'GetCapabilities')) { continue } $caps = $p.GetCapabilities() if ($null -eq $caps) { continue } $normalized = @(ConvertTo-IdleCapabilityList -Capabilities @($caps) -Normalize -Unique) if ($normalized -contains $normalizedCapability) { $matchingAliases.Add($alias) } } } if ($matchingAliases.Count -eq 1) { return $matchingAliases[0] } if ($matchingAliases.Count -gt 1) { $aliasList = $matchingAliases -join ', ' throw [System.ArgumentException]::new( "${ResolverPath}: Multiple providers advertise capability '$Capability': $aliasList. Specify 'With.Provider' in the resolver to disambiguate.", 'Providers' ) } throw [System.ArgumentException]::new( "$ResolverPath requires capability '$Capability' but no provider in the Providers map advertises it.", 'Providers' ) } function Invoke-IdleResolverCapabilityDispatch { <# .SYNOPSIS Dispatches a read-only capability call to the provider. .DESCRIPTION Maps the capability identifier to the appropriate provider method and invokes it with parameters extracted from the With hashtable. Passes AuthSession to methods that support it (backwards-compatible). #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Capability, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ProviderAlias, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Providers, [Parameter()] [AllowNull()] [System.Collections.IDictionary] $With, [Parameter()] [AllowNull()] [object] $AuthSession, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ResolverPath ) $provider = $Providers[$ProviderAlias] switch ($Capability) { 'IdLE.Entitlement.List' { if ($null -eq $With -or -not $With.Contains('IdentityKey') -or [string]::IsNullOrWhiteSpace([string]$With.IdentityKey)) { throw [System.ArgumentException]::new( "$ResolverPath with capability 'IdLE.Entitlement.List' requires With.IdentityKey (non-empty string).", 'Workflow' ) } $identityKey = [string]$With.IdentityKey $method = $provider.PSObject.Methods['ListEntitlements'] if ($null -eq $method) { throw [System.InvalidOperationException]::new( "${ResolverPath}: Provider '$ProviderAlias' does not implement 'ListEntitlements', which is required for capability 'IdLE.Entitlement.List'." ) } $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'AuthSession' if ($supportsAuthSession -and $null -ne $AuthSession) { return @($provider.ListEntitlements($identityKey, $AuthSession)) } return @($provider.ListEntitlements($identityKey)) } 'IdLE.Identity.Read' { if ($null -eq $With -or -not $With.Contains('IdentityKey') -or [string]::IsNullOrWhiteSpace([string]$With.IdentityKey)) { throw [System.ArgumentException]::new( "$ResolverPath with capability 'IdLE.Identity.Read' requires With.IdentityKey (non-empty string).", 'Workflow' ) } $identityKey = [string]$With.IdentityKey $method = $provider.PSObject.Methods['GetIdentity'] if ($null -eq $method) { throw [System.InvalidOperationException]::new( "${ResolverPath}: Provider '$ProviderAlias' does not implement 'GetIdentity', which is required for capability 'IdLE.Identity.Read'." ) } $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'AuthSession' if ($supportsAuthSession -and $null -ne $AuthSession) { return $provider.GetIdentity($identityKey, $AuthSession) } return $provider.GetIdentity($identityKey) } default { throw [System.InvalidOperationException]::new( "${ResolverPath}: No dispatch defined for capability '$Capability'. This is an engine bug." ) } } } function Set-IdleContextValue { <# .SYNOPSIS Sets a value at a dotted path within a hashtable (the Request.Context). .DESCRIPTION Navigates the dotted path, creating new hashtables for missing intermediate nodes, and assigns the value at the leaf. Throws if an existing intermediate node is not a dictionary (prevents silently discarding host-provided context). #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [System.Collections.IDictionary] $Context, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [Parameter()] [AllowNull()] [object] $Value ) $segments = $Path -split '\.' if ($segments.Count -eq 1) { $Context[$segments[0]] = $Value return } # Navigate/create intermediate hashtables $current = $Context for ($idx = 0; $idx -lt $segments.Count - 1; $idx++) { $seg = $segments[$idx] $existing = if ($current -is [System.Collections.IDictionary] -and $current.Contains($seg)) { $current[$seg] } else { $null } if ($null -eq $existing) { # Create a new intermediate hashtable when there is no existing value. $current[$seg] = @{} } elseif (-not ($existing -is [System.Collections.IDictionary])) { throw [System.InvalidOperationException]::new( ("Cannot set context path '{0}': intermediate node '{1}' is of type '{2}', expected a hashtable. Use a unique resolver output path to avoid conflicts with existing context data." -f $Path, $seg, $existing.GetType().FullName) ) } $current = $current[$seg] } $current[$segments[-1]] = $Value } |