From 500f29f69318869d30d9688c2e514e113c039298 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:35:52 +0000 Subject: [PATCH 1/4] Initial plan From 091f14d9106cc85d1b1d66aeb9c647c84b8f0f18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:56:29 +0000 Subject: [PATCH 2/4] feat: implement planning-time ContextResolvers for Request.Context population Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../mock/joiner-with-context-resolvers.psd1 | 61 +++ .../Private/Get-IdleReadOnlyCapabilities.ps1 | 30 ++ .../Private/Invoke-IdleContextResolvers.ps1 | 298 +++++++++++ .../Private/Test-IdleWorkflowSchema.ps1 | 55 ++- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 16 +- .../Test-IdleWorkflowDefinitionObject.ps1 | 13 +- .../Public/New-IdleMockIdentityProvider.ps1 | 2 +- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 463 ++++++++++++++++++ 8 files changed, 926 insertions(+), 12 deletions(-) create mode 100644 examples/workflows/mock/joiner-with-context-resolvers.psd1 create mode 100644 src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 create mode 100644 src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 create mode 100644 tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 diff --git a/examples/workflows/mock/joiner-with-context-resolvers.psd1 b/examples/workflows/mock/joiner-with-context-resolvers.psd1 new file mode 100644 index 00000000..6745f02a --- /dev/null +++ b/examples/workflows/mock/joiner-with-context-resolvers.psd1 @@ -0,0 +1,61 @@ +@{ + # Joiner workflow demonstrating ContextResolvers to enrich Request.Context at planning time. + # + # ContextResolvers run BEFORE step conditions are evaluated. They use read-only provider + # capabilities to fetch data and write it under Request.Context.*. + # + # This example uses IdLE.Entitlement.List to pre-fetch the identity's current entitlements. + # Steps can then reference Request.Context.Identity.Entitlements in their Condition. + + Name = 'Joiner - ContextResolvers Demo' + LifecycleEvent = 'Joiner' + + # Planning-time resolvers: run before condition evaluation, write to Request.Context.* + ContextResolvers = @( + @{ + # Fetch current entitlements for the identity being onboarded. + Capability = 'IdLE.Entitlement.List' + + # The provider alias that supports IdLE.Entitlement.List. + # If omitted, the first provider advertising the capability is used. + Provider = 'Identity' + + # Resolver inputs - use the identity key for this workflow. + # In real workflows, use template placeholders like '{{Request.IdentityKeys.EmployeeId}}'. + With = @{ + IdentityKey = 'user1' + } + + # Write resolved entitlements to Request.Context.Identity.Entitlements. + # 'To' must always start with 'Context.' (writes restricted to Request.Context.*). + To = 'Context.Identity.Entitlements' + } + ) + + Steps = @( + @{ + # Always runs - ensures the base group membership. + Name = 'EnsureBaseGroup' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ Kind = 'Group'; Id = 'all-employees'; DisplayName = 'All Employees' } + State = 'Present' + Provider = 'Identity' + } + } + + @{ + # Only grant the IT team group if the identity does not already have it. + # This uses the pre-resolved entitlements from Request.Context.Identity.Entitlements. + Name = 'EmitContextAvailable' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ + Exists = 'Request.Context.Identity.Entitlements' + } + With = @{ + Message = 'Entitlement context was pre-resolved by ContextResolvers and is available for planning.' + } + } + ) +} diff --git a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 new file mode 100644 index 00000000..2ea98596 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 @@ -0,0 +1,30 @@ +Set-StrictMode -Version Latest + +function Get-IdleReadOnlyCapabilities { + <# + .SYNOPSIS + Returns the allow-list of read-only capabilities usable in ContextResolvers. + + .DESCRIPTION + ContextResolvers may only invoke capabilities from this allow-list. + This enforces the read-only guarantee at planning time: resolvers cannot + trigger mutations or side effects via the planning pipeline. + + Only capabilities that are safe to invoke at planning time (no side effects, + deterministic, serializable output) should be added to this list. + + .OUTPUTS + String[] + + .EXAMPLE + $allowed = Get-IdleReadOnlyCapabilities + # Returns: @('IdLE.Entitlement.List') + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + return @( + 'IdLE.Entitlement.List' + ) +} diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 new file mode 100644 index 00000000..832f3b5a --- /dev/null +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -0,0 +1,298 @@ +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 at the + path specified by the resolver's 'To' property. + + Rules enforced: + - Only capabilities in the read-only allow-list (Get-IdleReadOnlyCapabilities) may be used. + - 'To' must start with 'Context.' (writes are restricted to Request.Context.*). + - Provider is selected by alias when 'Provider' is specified; otherwise the first + provider that advertises the capability is used. + + This function mutates Request.Context in place so that subsequent condition evaluation + can reference the resolved data via 'Request.Context.*' paths. + + .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). + + .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' + ) + } + + # --- To --- + if (-not $resolver.Contains('To') -or [string]::IsNullOrWhiteSpace([string]$resolver.To)) { + throw [System.ArgumentException]::new("$resolverPath is missing required key 'To'.", 'Workflow') + } + + $to = [string]$resolver.To + + if (-not $to.StartsWith('Context.', [System.StringComparison]::OrdinalIgnoreCase)) { + throw [System.ArgumentException]::new( + "$resolverPath 'To' value '$to' must start with 'Context.' (writes are restricted to Request.Context.*).", + 'Workflow' + ) + } + + $contextSubPath = $to.Substring('Context.'.Length) + + if ([string]::IsNullOrWhiteSpace($contextSubPath)) { + throw [System.ArgumentException]::new( + "$resolverPath 'To' value '$to' must specify a path under 'Context.' (e.g., 'Context.Identity.Entitlements').", + '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 ($resolver.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$resolver.Provider)) { + [string]$resolver.Provider + } + else { + $null + } + + $provider = Select-IdleResolverProvider -Capability $capability -ProviderAlias $providerAlias -Providers $Providers -ResolverPath $resolverPath + + # --- Dispatch --- + $result = Invoke-IdleResolverCapabilityDispatch -Capability $capability -Provider $provider -With $with -ResolverPath $resolverPath + + # --- Write to Request.Context --- + Set-IdleContextValue -Context $Request.Context -Path $contextSubPath -Value $result + + $i++ + } +} + +function Select-IdleResolverProvider { + <# + .SYNOPSIS + Selects the appropriate provider for a context resolver capability. + + .DESCRIPTION + If ProviderAlias is given, looks up that key in Providers. + Otherwise, selects the first provider that advertises the capability. + #> + [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 $Providers[$ProviderAlias] + } + + # Auto-select: find first provider advertising the capability + $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) + + foreach ($p in $providerInstances) { + if ($null -eq $p) { 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) + $normalizedCapability = ConvertTo-IdleNormalizedCapability -Capability $Capability + + if ($normalized -contains $normalizedCapability) { + return $p + } + } + + 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. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Capability, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Provider, + + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] $With, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ResolverPath + ) + + 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 + + if (-not ($Provider.PSObject.Methods.Name -contains 'ListEntitlements')) { + throw [System.InvalidOperationException]::new( + "${ResolverPath}: Provider does not implement 'ListEntitlements', which is required for capability 'IdLE.Entitlement.List'." + ) + } + + return @($Provider.ListEntitlements($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 intermediate hashtables as needed, + and assigns the value at the leaf node. + #> + [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 -or -not ($existing -is [System.Collections.IDictionary])) { + $current[$seg] = @{} + } + + $current = $current[$seg] + } + + $current[$segments[-1]] = $Value +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 6e37a514..82cba7cf 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -57,7 +57,7 @@ function Test-IdleWorkflowSchema { } } - $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description') + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description', 'ContextResolvers') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") @@ -176,5 +176,58 @@ function Test-IdleWorkflowSchema { } } + # ContextResolvers are optional. If present, validate each resolver entry. + if ($Workflow.ContainsKey('ContextResolvers') -and $null -ne $Workflow.ContextResolvers) { + if ($Workflow.ContextResolvers -isnot [System.Collections.IEnumerable] -or $Workflow.ContextResolvers -is [string]) { + $errors.Add("'ContextResolvers' must be an array/list of resolver hashtables.") + } + else { + $allowedResolverKeys = @('Capability', 'To', 'Provider', 'With') + + $i = 0 + foreach ($resolver in $Workflow.ContextResolvers) { + $resolverPath = "ContextResolvers[$i]" + + if ($null -eq $resolver -or $resolver -isnot [hashtable]) { + $errors.Add("$resolverPath must be a hashtable.") + $i++ + continue + } + + foreach ($k in $resolver.Keys) { + if ($allowedResolverKeys -notcontains $k) { + $errors.Add("Unknown key '$k' in $resolverPath. Allowed keys: $($allowedResolverKeys -join ', ').") + } + } + + if (-not $resolver.ContainsKey('Capability') -or [string]::IsNullOrWhiteSpace([string]$resolver.Capability)) { + $errors.Add("Missing or empty required key '$resolverPath.Capability'.") + } + + if (-not $resolver.ContainsKey('To') -or [string]::IsNullOrWhiteSpace([string]$resolver.To)) { + $errors.Add("Missing or empty required key '$resolverPath.To'.") + } + elseif (-not ([string]$resolver.To).StartsWith('Context.', [System.StringComparison]::OrdinalIgnoreCase)) { + $errors.Add("'$resolverPath.To' value '$($resolver.To)' must start with 'Context.' (writes are restricted to Request.Context.*).") + } + elseif ([string]::IsNullOrWhiteSpace(([string]$resolver.To).Substring('Context.'.Length))) { + $errors.Add("'$resolverPath.To' must specify a path under 'Context.' (e.g., 'Context.Identity.Entitlements').") + } + + # 'With' is optional but must be a hashtable if present. + if ($resolver.ContainsKey('With') -and $null -ne $resolver.With -and $resolver.With -isnot [hashtable]) { + $errors.Add("'$resolverPath.With' must be a hashtable (resolver input parameters).") + } + + # 'Provider' is optional but must be a non-empty string if present. + if ($resolver.ContainsKey('Provider') -and $null -ne $resolver.Provider -and [string]::IsNullOrWhiteSpace([string]$resolver.Provider)) { + $errors.Add("'$resolverPath.Provider' must not be an empty string.") + } + + $i++ + } + } + } + return $errors } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index ac14a2df..3e55e4df 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -8,6 +8,7 @@ function New-IdlePlanObject { This is a planning-only artifact. Execution is handled by Invoke-IdlePlanObject later. Planning responsibilities: + - Execute ContextResolvers (read-only) to populate Request.Context before condition evaluation. - Create a data-only request snapshot for deterministic exports and auditing. - Normalize workflow steps to IdLE.PlanStep objects. - Evaluate step conditions during planning and mark steps as NotApplicable. @@ -62,7 +63,17 @@ function New-IdlePlanObject { ) } - # Create a data-only snapshot of the incoming request for deterministic exports. + # Validate workflow and ensure it matches the request's LifecycleEvent. + $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + + # Execute ContextResolvers (planning-time, read-only) before condition evaluation. + # Resolvers populate Request.Context.* so that conditions can reference resolved data. + $workflowContextResolvers = Get-IdlePropertyValue -Object $workflow -Name 'ContextResolvers' + if ($null -ne $workflowContextResolvers -and @($workflowContextResolvers).Count -gt 0) { + Invoke-IdleContextResolvers -Resolvers @($workflowContextResolvers) -Providers $Providers -Request $Request + } + + # Create a data-only snapshot AFTER resolvers have run so that resolved context is captured. $requestSnapshot = [pscustomobject]@{ PSTypeName = 'IdLE.LifecycleRequestSnapshot' LifecycleEvent = ConvertTo-NullIfEmptyString -Value ([string]$Request.LifecycleEvent) @@ -73,9 +84,6 @@ function New-IdlePlanObject { Context = if ($reqProps -contains 'Context') { Copy-IdleDataObject -Value $Request.Context } else { $null } } - # Validate workflow and ensure it matches the request's LifecycleEvent. - $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request - # Create the plan object (planning artifact). $plan = [pscustomobject]@{ PSTypeName = 'IdLE.Plan' diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index 25f52b9f..a2f8ebce 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -97,11 +97,12 @@ function Test-IdleWorkflowDefinitionObject { # 5) Return normalized object (stable contract for planning). # PSCustomObject avoids class/type load-order problems across modules. return [pscustomobject]@{ - PSTypeName = 'IdLE.WorkflowDefinition' - Name = [string]$workflow.Name - LifecycleEvent = [string]$workflow.LifecycleEvent - Description = if ($workflow.ContainsKey('Description')) { [string]$workflow.Description } else { $null } - Steps = @($workflow.Steps) - OnFailureSteps = if ($workflow.ContainsKey('OnFailureSteps') -and $null -ne $workflow.OnFailureSteps) { @($workflow.OnFailureSteps) } else { @() } + PSTypeName = 'IdLE.WorkflowDefinition' + Name = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + Description = if ($workflow.ContainsKey('Description')) { [string]$workflow.Description } else { $null } + Steps = @($workflow.Steps) + OnFailureSteps = if ($workflow.ContainsKey('OnFailureSteps') -and $null -ne $workflow.OnFailureSteps) { @($workflow.OnFailureSteps) } else { @() } + ContextResolvers = if ($workflow.ContainsKey('ContextResolvers') -and $null -ne $workflow.ContextResolvers) { @($workflow.ContextResolvers) } else { @() } } } diff --git a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 index 0c8bd0bc..498a6f73 100644 --- a/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 +++ b/src/IdLE.Provider.Mock/Public/New-IdleMockIdentityProvider.ps1 @@ -283,7 +283,7 @@ function New-IdleMockIdentityProvider { ) if (-not $this.Store.ContainsKey($IdentityKey)) { - throw "Identity '$IdentityKey' does not exist in the mock provider store." + return @() } $identity = $this.Store[$IdentityKey] diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 new file mode 100644 index 00000000..0240229f --- /dev/null +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -0,0 +1,463 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + function global:Invoke-IdleContextResolverTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleContextResolverTestNoopStep' -ErrorAction SilentlyContinue +} + +Describe 'New-IdlePlan - ContextResolvers' { + + Context 'Resolver runs before conditions and influences step applicability' { + It 'resolver populates Request.Context and condition references resolved data' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-condition.psd1' -Content @' +@{ + Name = 'Resolver Condition Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'user1' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ + Name = 'ConditionalStep' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{} + Entitlements = @( + @{ Kind = 'Group'; Id = 'g1'; DisplayName = 'Group 1' } + ) + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps[0].Status | Should -Be 'Planned' + + # Snapshot captures resolved context + $plan.Request.Context | Should -Not -BeNullOrEmpty + $plan.Request.Context.Identity | Should -Not -BeNullOrEmpty + $entitlements = @($plan.Request.Context.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].Id | Should -Be 'g1' + } + + It 'step is NotApplicable when resolver returns empty entitlements and condition requires them' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-empty.psd1' -Content @' +@{ + Name = 'Resolver Empty Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'user2' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ + Name = 'NeedsEntitlements' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user2' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user2' = @{ + IdentityKey = 'user2' + Enabled = $true + Attributes = @{} + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Resolver ran but returned an empty list. + # PowerShell collapses empty arrays to $null in pipeline output, so Get-IdleValueByPath + # returns $null for the path, and the Exists condition evaluates to $false. + $plan | Should -Not -BeNullOrEmpty + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + } + + Context 'To path validation' { + It 'rejects To value outside Context. namespace' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-bad-to.psd1' -Content @' +@{ + Name = 'Bad To Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + To = 'Intent.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*must start with 'Context.'*" + } + + It 'rejects To = Context. without a sub-path' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-bare-context.psd1' -Content @' +@{ + Name = 'Bare Context To Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + To = 'Context.' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + # Schema validation rejects empty sub-path (starts with 'Context.' but nothing after) + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + } + } + + Context 'Non-allow-listed capability' { + It 'rejects a resolver that uses a capability not in the read-only allow-list' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-mutating-cap.psd1' -Content @' +@{ + Name = 'Mutating Capability Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.Grant' + With = @{ IdentityKey = 'user1' } + To = 'Context.Result' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*not in the read-only allow-list*" + } + } + + Context 'Resolver output captured in plan snapshot' { + It 'plan.Request.Context contains the resolved value after planning' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-snapshot.psd1' -Content @' +@{ + Name = 'Snapshot Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'snap-user' } + To = 'Context.Snap.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'snap-user' = @{ + IdentityKey = 'snap-user' + Enabled = $true + Attributes = @{} + Entitlements = @( + @{ Kind = 'Role'; Id = 'admin'; DisplayName = 'Admin Role' } + ) + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Request | Should -Not -BeNullOrEmpty + $plan.Request.Context | Should -Not -BeNullOrEmpty + $plan.Request.Context.Snap | Should -Not -BeNullOrEmpty + $snap = @($plan.Request.Context.Snap.Entitlements) + $snap.Count | Should -Be 1 + $snap[0].Kind | Should -Be 'Role' + $snap[0].Id | Should -Be 'admin' + } + } + + Context 'Provider auto-selection' { + It 'auto-selects provider when Provider is not specified in resolver' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-autoselect.psd1' -Content @' +@{ + Name = 'Auto Select Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'auto-user' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'auto-user' = @{ + IdentityKey = 'auto-user' + Enabled = $true + Attributes = @{} + Entitlements = @( + @{ Kind = 'Group'; Id = 'grp-auto'; DisplayName = 'Auto Group' } + ) + } + } + + # Provider is registered without an explicit alias in the resolver + $providers = @{ + IdentityProvider = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $entitlements = @($plan.Request.Context.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].Id | Should -Be 'grp-auto' + } + + It 'fails when no provider supports the capability and Provider is not specified' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-no-provider.psd1' -Content @' +@{ + Name = 'No Provider Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + # No provider supports IdLE.Entitlement.List + $dummyProvider = [pscustomobject]@{} + $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @() } + + $providers = @{ + Dummy = $dummyProvider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage "*no provider*Providers map*" + } + } + + Context 'Workflow schema validation for ContextResolvers' { + It 'rejects unknown keys in a resolver entry' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-unknown-key.psd1' -Content @' +@{ + Name = 'Unknown Key Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + To = 'Context.Identity.Entitlements' + UnknownKey = 'bad' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*Unknown key*UnknownKey*" + } + + It 'rejects a resolver missing the required Capability key' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-missing-cap.psd1' -Content @' +@{ + Name = 'Missing Capability Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + With = @{ IdentityKey = 'user1' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*Capability*" + } + + It 'rejects a resolver missing the required To key' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-missing-to.psd1' -Content @' +@{ + Name = 'Missing To Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*To*" + } + } + + Context 'Template resolution in With' { + It 'resolves Request.IdentityKeys template in With.IdentityKey' { + $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-template.psd1' -Content @' +@{ + Name = 'Template Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = '{{Request.IdentityKeys.Id}}' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'tmpl-user' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'tmpl-user' = @{ + IdentityKey = 'tmpl-user' + Enabled = $true + Attributes = @{} + Entitlements = @( + @{ Kind = 'Group'; Id = 'tmpl-grp'; DisplayName = 'Template Group' } + ) + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $entitlements = @($plan.Request.Context.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].Id | Should -Be 'tmpl-grp' + } + } +} From 12c2d286b162958e8df8f551e28f91cd37e99860 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:24:22 +0000 Subject: [PATCH 3/4] refactor: remove To from ContextResolvers schema, add predefined paths per capability, IdLE.Identity.Read support, fixture files Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/capabilities.md | 38 +++ .../mock/joiner-with-context-resolvers.psd1 | 28 +- .../Private/Get-IdleReadOnlyCapabilities.ps1 | 53 +++- .../Private/Invoke-IdleContextResolvers.ps1 | 50 ++-- .../Private/Test-IdleWorkflowSchema.ps1 | 13 +- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 275 +++--------------- .../workflows/resolver-autoselect.psd1 | 13 + .../workflows/resolver-condition.psd1 | 18 ++ .../resolver-empty-entitlements.psd1 | 18 ++ .../workflows/resolver-identity-read.psd1 | 18 ++ .../resolver-missing-capability.psd1 | 12 + .../workflows/resolver-no-provider.psd1 | 13 + .../resolver-non-allowlisted-cap.psd1 | 13 + .../fixtures/workflows/resolver-snapshot.psd1 | 14 + .../fixtures/workflows/resolver-template.psd1 | 14 + .../workflows/resolver-unknown-key.psd1 | 14 + .../workflows/resolver-with-to-key.psd1 | 14 + 17 files changed, 338 insertions(+), 280 deletions(-) create mode 100644 tests/fixtures/workflows/resolver-autoselect.psd1 create mode 100644 tests/fixtures/workflows/resolver-condition.psd1 create mode 100644 tests/fixtures/workflows/resolver-empty-entitlements.psd1 create mode 100644 tests/fixtures/workflows/resolver-identity-read.psd1 create mode 100644 tests/fixtures/workflows/resolver-missing-capability.psd1 create mode 100644 tests/fixtures/workflows/resolver-no-provider.psd1 create mode 100644 tests/fixtures/workflows/resolver-non-allowlisted-cap.psd1 create mode 100644 tests/fixtures/workflows/resolver-snapshot.psd1 create mode 100644 tests/fixtures/workflows/resolver-template.psd1 create mode 100644 tests/fixtures/workflows/resolver-unknown-key.psd1 create mode 100644 tests/fixtures/workflows/resolver-with-to-key.psd1 diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 58bab946..ce79aee0 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -125,3 +125,41 @@ Recommended provider documentation pattern: - List supported capabilities in the provider documentation. - If a capability is only partially supported (e.g., limited attribute set), document constraints explicitly. + + +--- + +## ContextResolvers: read-only capabilities and predefined Context paths + +Workflows may declare a `ContextResolvers` section to populate `Request.Context.*` at planning time using read-only provider capabilities. Only the capabilities listed below are permitted in `ContextResolvers`. + +Each capability writes to a **predefined, fixed path** under `Request.Context`. This path is not user-configurable, which prevents accidental overwrites and ensures a consistent context shape across all workflows. + +| Capability | Predefined `Request.Context` path | Required `With` keys | +|---|---|---| +| `IdLE.Entitlement.List` | `Request.Context.Identity.Entitlements` | `IdentityKey` (string) | +| `IdLE.Identity.Read` | `Request.Context.Identity.Profile` | `IdentityKey` (string) | + +### Example + +```powershell +ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' # optional; auto-selected if omitted + With = @{ IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' } + # Writes to Request.Context.Identity.Entitlements (predefined, not configurable) + } + @{ + Capability = 'IdLE.Identity.Read' + With = @{ IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' } + # Writes to Request.Context.Identity.Profile (predefined, not configurable) + } +) +``` + +Steps can then reference the resolved data in their `Condition`: + +```powershell +Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } +``` diff --git a/examples/workflows/mock/joiner-with-context-resolvers.psd1 b/examples/workflows/mock/joiner-with-context-resolvers.psd1 index 6745f02a..059c7423 100644 --- a/examples/workflows/mock/joiner-with-context-resolvers.psd1 +++ b/examples/workflows/mock/joiner-with-context-resolvers.psd1 @@ -4,31 +4,29 @@ # ContextResolvers run BEFORE step conditions are evaluated. They use read-only provider # capabilities to fetch data and write it under Request.Context.*. # - # This example uses IdLE.Entitlement.List to pre-fetch the identity's current entitlements. - # Steps can then reference Request.Context.Identity.Entitlements in their Condition. + # Each capability writes to a predefined path (no user-configurable 'To'): + # IdLE.Entitlement.List -> Request.Context.Identity.Entitlements + # IdLE.Identity.Read -> Request.Context.Identity.Profile Name = 'Joiner - ContextResolvers Demo' LifecycleEvent = 'Joiner' - # Planning-time resolvers: run before condition evaluation, write to Request.Context.* + # Planning-time resolvers: run before condition evaluation. + # Each capability has a predefined output path under Request.Context.* ContextResolvers = @( @{ # Fetch current entitlements for the identity being onboarded. + # Writes to Request.Context.Identity.Entitlements (predefined). Capability = 'IdLE.Entitlement.List' # The provider alias that supports IdLE.Entitlement.List. # If omitted, the first provider advertising the capability is used. Provider = 'Identity' - # Resolver inputs - use the identity key for this workflow. - # In real workflows, use template placeholders like '{{Request.IdentityKeys.EmployeeId}}'. + # Resolver inputs. With = @{ IdentityKey = 'user1' } - - # Write resolved entitlements to Request.Context.Identity.Entitlements. - # 'To' must always start with 'Context.' (writes restricted to Request.Context.*). - To = 'Context.Identity.Entitlements' } ) @@ -38,16 +36,16 @@ Name = 'EnsureBaseGroup' Type = 'IdLE.Step.EnsureEntitlement' With = @{ - IdentityKey = 'user1' - Entitlement = @{ Kind = 'Group'; Id = 'all-employees'; DisplayName = 'All Employees' } - State = 'Present' - Provider = 'Identity' + IdentityKey = 'user1' + Entitlement = @{ Kind = 'Group'; Id = 'all-employees' } + State = 'Present' + Provider = 'Identity' } } @{ - # Only grant the IT team group if the identity does not already have it. - # This uses the pre-resolved entitlements from Request.Context.Identity.Entitlements. + # Runs only when entitlements were successfully pre-resolved by the ContextResolver. + # References the predefined context path for IdLE.Entitlement.List. Name = 'EmitContextAvailable' Type = 'IdLE.Step.EmitEvent' Condition = @{ diff --git a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 index 2ea98596..634bc049 100644 --- a/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 +++ b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 @@ -13,12 +13,15 @@ function Get-IdleReadOnlyCapabilities { Only capabilities that are safe to invoke at planning time (no side effects, deterministic, serializable output) should be added to this list. + Each capability in this list has a predefined output path in Request.Context + (see Get-IdleCapabilityContextPath). + .OUTPUTS String[] .EXAMPLE $allowed = Get-IdleReadOnlyCapabilities - # Returns: @('IdLE.Entitlement.List') + # Returns: @('IdLE.Entitlement.List', 'IdLE.Identity.Read') #> [CmdletBinding()] [OutputType([string[]])] @@ -26,5 +29,53 @@ function Get-IdleReadOnlyCapabilities { return @( 'IdLE.Entitlement.List' + 'IdLE.Identity.Read' ) } + +function Get-IdleCapabilityContextPath { + <# + .SYNOPSIS + Returns the predefined Request.Context sub-path for a read-only capability. + + .DESCRIPTION + Each read-only capability allowed in ContextResolvers writes its result to a fixed, + predefined path under Request.Context. This prevents user-configurable overwrites + and ensures consistent context shape across workflows. + + The path is relative to Request.Context (e.g., 'Identity.Entitlements' maps to + Request.Context.Identity.Entitlements). + + .PARAMETER Capability + The capability identifier (must be in the read-only allow-list). + + .OUTPUTS + String + + .EXAMPLE + Get-IdleCapabilityContextPath -Capability 'IdLE.Entitlement.List' + # Returns: 'Identity.Entitlements' + + .EXAMPLE + Get-IdleCapabilityContextPath -Capability 'IdLE.Identity.Read' + # Returns: 'Identity.Profile' + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Capability + ) + + switch ($Capability) { + 'IdLE.Entitlement.List' { return 'Identity.Entitlements' } + 'IdLE.Identity.Read' { return 'Identity.Profile' } + default { + throw [System.ArgumentException]::new( + "No predefined context path defined for capability '$Capability'.", + 'Capability' + ) + } + } +} diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index 832f3b5a..bc73a115 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -8,11 +8,12 @@ function Invoke-IdleContextResolvers { .DESCRIPTION Runs each configured resolver in declared order, invoking the appropriate provider capability and writing the result under Request.Context at the - path specified by the resolver's 'To' property. + predefined path for that capability (see Get-IdleCapabilityContextPath). Rules enforced: - Only capabilities in the read-only allow-list (Get-IdleReadOnlyCapabilities) may be used. - - 'To' must start with 'Context.' (writes are restricted to Request.Context.*). + - Each capability writes to a fixed, predefined path under Request.Context. + The output path is not user-configurable. - Provider is selected by alias when 'Provider' is specified; otherwise the first provider that advertises the capability is used. @@ -72,29 +73,6 @@ function Invoke-IdleContextResolvers { ) } - # --- To --- - if (-not $resolver.Contains('To') -or [string]::IsNullOrWhiteSpace([string]$resolver.To)) { - throw [System.ArgumentException]::new("$resolverPath is missing required key 'To'.", 'Workflow') - } - - $to = [string]$resolver.To - - if (-not $to.StartsWith('Context.', [System.StringComparison]::OrdinalIgnoreCase)) { - throw [System.ArgumentException]::new( - "$resolverPath 'To' value '$to' must start with 'Context.' (writes are restricted to Request.Context.*).", - 'Workflow' - ) - } - - $contextSubPath = $to.Substring('Context.'.Length) - - if ([string]::IsNullOrWhiteSpace($contextSubPath)) { - throw [System.ArgumentException]::new( - "$resolverPath 'To' value '$to' must specify a path under 'Context.' (e.g., 'Context.Identity.Entitlements').", - 'Workflow' - ) - } - # --- With (optional, template-resolved) --- $with = if ($resolver.Contains('With') -and $null -ne $resolver.With) { Copy-IdleDataObject -Value $resolver.With @@ -123,7 +101,8 @@ function Invoke-IdleContextResolvers { # --- Dispatch --- $result = Invoke-IdleResolverCapabilityDispatch -Capability $capability -Provider $provider -With $with -ResolverPath $resolverPath - # --- Write to Request.Context --- + # --- Write to predefined Request.Context path --- + $contextSubPath = Get-IdleCapabilityContextPath -Capability $capability Set-IdleContextValue -Context $Request.Context -Path $contextSubPath -Value $result $i++ @@ -242,6 +221,25 @@ function Invoke-IdleResolverCapabilityDispatch { 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 + + if (-not ($Provider.PSObject.Methods.Name -contains 'GetIdentity')) { + throw [System.InvalidOperationException]::new( + "${ResolverPath}: Provider does not implement 'GetIdentity', which is required for capability 'IdLE.Identity.Read'." + ) + } + + return $Provider.GetIdentity($identityKey) + } + default { throw [System.InvalidOperationException]::new( "${ResolverPath}: No dispatch defined for capability '$Capability'. This is an engine bug." diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 82cba7cf..733b6ecf 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -182,7 +182,8 @@ function Test-IdleWorkflowSchema { $errors.Add("'ContextResolvers' must be an array/list of resolver hashtables.") } else { - $allowedResolverKeys = @('Capability', 'To', 'Provider', 'With') + # 'To' is not user-configurable; each capability has a predefined output path. + $allowedResolverKeys = @('Capability', 'Provider', 'With') $i = 0 foreach ($resolver in $Workflow.ContextResolvers) { @@ -204,16 +205,6 @@ function Test-IdleWorkflowSchema { $errors.Add("Missing or empty required key '$resolverPath.Capability'.") } - if (-not $resolver.ContainsKey('To') -or [string]::IsNullOrWhiteSpace([string]$resolver.To)) { - $errors.Add("Missing or empty required key '$resolverPath.To'.") - } - elseif (-not ([string]$resolver.To).StartsWith('Context.', [System.StringComparison]::OrdinalIgnoreCase)) { - $errors.Add("'$resolverPath.To' value '$($resolver.To)' must start with 'Context.' (writes are restricted to Request.Context.*).") - } - elseif ([string]::IsNullOrWhiteSpace(([string]$resolver.To).Substring('Context.'.Length))) { - $errors.Add("'$resolverPath.To' must specify a path under 'Context.' (e.g., 'Context.Identity.Entitlements').") - } - # 'With' is optional but must be a hashtable if present. if ($resolver.ContainsKey('With') -and $null -ne $resolver.With -and $resolver.With -isnot [hashtable]) { $errors.Add("'$resolverPath.With' must be a hashtable (resolver input parameters).") diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 0240229f..8ed523e5 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -4,6 +4,8 @@ BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + $script:FixturesPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows' + function global:Invoke-IdleContextResolverTestNoopStep { [CmdletBinding()] param( @@ -34,27 +36,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Context 'Resolver runs before conditions and influences step applicability' { It 'resolver populates Request.Context and condition references resolved data' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-condition.psd1' -Content @' -@{ - Name = 'Resolver Condition Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - Provider = 'Identity' - With = @{ IdentityKey = 'user1' } - To = 'Context.Identity.Entitlements' - } - ) - Steps = @( - @{ - Name = 'ConditionalStep' - Type = 'IdLE.Step.EmitEvent' - Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-condition.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } @@ -64,7 +46,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Enabled = $true Attributes = @{} Entitlements = @( - @{ Kind = 'Group'; Id = 'g1'; DisplayName = 'Group 1' } + @{ Kind = 'Group'; Id = 'g1' } ) } } @@ -79,7 +61,7 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan | Should -Not -BeNullOrEmpty $plan.Steps[0].Status | Should -Be 'Planned' - # Snapshot captures resolved context + # Snapshot captures resolved context (predefined path: Identity.Entitlements) $plan.Request.Context | Should -Not -BeNullOrEmpty $plan.Request.Context.Identity | Should -Not -BeNullOrEmpty $entitlements = @($plan.Request.Context.Identity.Entitlements) @@ -88,27 +70,7 @@ Describe 'New-IdlePlan - ContextResolvers' { } It 'step is NotApplicable when resolver returns empty entitlements and condition requires them' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-empty.psd1' -Content @' -@{ - Name = 'Resolver Empty Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - Provider = 'Identity' - With = @{ IdentityKey = 'user2' } - To = 'Context.Identity.Entitlements' - } - ) - Steps = @( - @{ - Name = 'NeedsEntitlements' - Type = 'IdLE.Step.EmitEvent' - Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-empty-entitlements.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user2' } @@ -134,76 +96,50 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan | Should -Not -BeNullOrEmpty $plan.Steps[0].Status | Should -Be 'NotApplicable' } - } - Context 'To path validation' { - It 'rejects To value outside Context. namespace' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-bad-to.psd1' -Content @' -@{ - Name = 'Bad To Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = 'user1' } - To = 'Intent.Entitlements' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + It 'IdLE.Identity.Read resolver populates Request.Context.Identity.Profile' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } - { New-IdlePlan -WorkflowPath $wfPath -Request $req } | - Should -Throw -ExpectedMessage "*must start with 'Context.'*" - } + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ Department = 'IT' } + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - It 'rejects To = Context. without a sub-path' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-bare-context.psd1' -Content @' -@{ - Name = 'Bare Context To Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = 'user1' } - To = 'Context.' + $plan | Should -Not -BeNullOrEmpty + # Predefined path for IdLE.Identity.Read is Identity.Profile + $plan.Steps[0].Status | Should -Be 'Planned' + $plan.Request.Context.Identity.Profile | Should -Not -BeNullOrEmpty + $plan.Request.Context.Identity.Profile.IdentityKey | Should -Be 'user1' + } } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + + Context 'To is not a supported key (output path is predefined per capability)' { + It 'rejects a resolver entry that specifies To (unknown key)' { + $wfPath = Join-Path $script:FixturesPath 'resolver-with-to-key.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - # Schema validation rejects empty sub-path (starts with 'Context.' but nothing after) - { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*Unknown key*To*" } } Context 'Non-allow-listed capability' { It 'rejects a resolver that uses a capability not in the read-only allow-list' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-mutating-cap.psd1' -Content @' -@{ - Name = 'Mutating Capability Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.Grant' - With = @{ IdentityKey = 'user1' } - To = 'Context.Result' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-non-allowlisted-cap.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -214,23 +150,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Context 'Resolver output captured in plan snapshot' { It 'plan.Request.Context contains the resolved value after planning' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-snapshot.psd1' -Content @' -@{ - Name = 'Snapshot Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - Provider = 'Identity' - With = @{ IdentityKey = 'snap-user' } - To = 'Context.Snap.Entitlements' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-snapshot.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -240,7 +160,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Enabled = $true Attributes = @{} Entitlements = @( - @{ Kind = 'Role'; Id = 'admin'; DisplayName = 'Admin Role' } + @{ Kind = 'Role'; Id = 'admin' } ) } } @@ -254,8 +174,8 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan.Request | Should -Not -BeNullOrEmpty $plan.Request.Context | Should -Not -BeNullOrEmpty - $plan.Request.Context.Snap | Should -Not -BeNullOrEmpty - $snap = @($plan.Request.Context.Snap.Entitlements) + # IdLE.Entitlement.List always writes to predefined path: Identity.Entitlements + $snap = @($plan.Request.Context.Identity.Entitlements) $snap.Count | Should -Be 1 $snap[0].Kind | Should -Be 'Role' $snap[0].Id | Should -Be 'admin' @@ -264,22 +184,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Context 'Provider auto-selection' { It 'auto-selects provider when Provider is not specified in resolver' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-autoselect.psd1' -Content @' -@{ - Name = 'Auto Select Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = 'auto-user' } - To = 'Context.Identity.Entitlements' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-autoselect.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -289,7 +194,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Enabled = $true Attributes = @{} Entitlements = @( - @{ Kind = 'Group'; Id = 'grp-auto'; DisplayName = 'Auto Group' } + @{ Kind = 'Group'; Id = 'grp-auto' } ) } } @@ -309,22 +214,7 @@ Describe 'New-IdlePlan - ContextResolvers' { } It 'fails when no provider supports the capability and Provider is not specified' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-no-provider.psd1' -Content @' -@{ - Name = 'No Provider Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = 'user1' } - To = 'Context.Identity.Entitlements' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-no-provider.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -344,23 +234,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Context 'Workflow schema validation for ContextResolvers' { It 'rejects unknown keys in a resolver entry' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-unknown-key.psd1' -Content @' -@{ - Name = 'Unknown Key Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = 'user1' } - To = 'Context.Identity.Entitlements' - UnknownKey = 'bad' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-unknown-key.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -369,71 +243,18 @@ Describe 'New-IdlePlan - ContextResolvers' { } It 'rejects a resolver missing the required Capability key' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-missing-cap.psd1' -Content @' -@{ - Name = 'Missing Capability Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - With = @{ IdentityKey = 'user1' } - To = 'Context.Identity.Entitlements' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-missing-capability.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw -ExpectedMessage "*Capability*" } - - It 'rejects a resolver missing the required To key' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-missing-to.psd1' -Content @' -@{ - Name = 'Missing To Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = 'user1' } - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ - - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' - - { New-IdlePlan -WorkflowPath $wfPath -Request $req } | - Should -Throw -ExpectedMessage "*To*" - } } Context 'Template resolution in With' { It 'resolves Request.IdentityKeys template in With.IdentityKey' { - $wfPath = New-IdleTestWorkflowFile -FileName 'resolver-template.psd1' -Content @' -@{ - Name = 'Template Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - Provider = 'Identity' - With = @{ IdentityKey = '{{Request.IdentityKeys.Id}}' } - To = 'Context.Identity.Entitlements' - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'resolver-template.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'tmpl-user' } @@ -443,7 +264,7 @@ Describe 'New-IdlePlan - ContextResolvers' { Enabled = $true Attributes = @{} Entitlements = @( - @{ Kind = 'Group'; Id = 'tmpl-grp'; DisplayName = 'Template Group' } + @{ Kind = 'Group'; Id = 'tmpl-grp' } ) } } diff --git a/tests/fixtures/workflows/resolver-autoselect.psd1 b/tests/fixtures/workflows/resolver-autoselect.psd1 new file mode 100644 index 00000000..fdb623b9 --- /dev/null +++ b/tests/fixtures/workflows/resolver-autoselect.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Resolver Auto-Select Provider Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'auto-user' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-condition.psd1 b/tests/fixtures/workflows/resolver-condition.psd1 new file mode 100644 index 00000000..c38ad0fe --- /dev/null +++ b/tests/fixtures/workflows/resolver-condition.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Resolver Condition Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ + Name = 'ConditionalStep' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + } + ) +} diff --git a/tests/fixtures/workflows/resolver-empty-entitlements.psd1 b/tests/fixtures/workflows/resolver-empty-entitlements.psd1 new file mode 100644 index 00000000..2202d279 --- /dev/null +++ b/tests/fixtures/workflows/resolver-empty-entitlements.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Resolver Empty Entitlements Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'user2' } + } + ) + Steps = @( + @{ + Name = 'NeedsEntitlements' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + } + ) +} diff --git a/tests/fixtures/workflows/resolver-identity-read.psd1 b/tests/fixtures/workflows/resolver-identity-read.psd1 new file mode 100644 index 00000000..ab299681 --- /dev/null +++ b/tests/fixtures/workflows/resolver-identity-read.psd1 @@ -0,0 +1,18 @@ +@{ + Name = 'Resolver Identity Read Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Identity.Read' + Provider = 'Identity' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ + Name = 'ConditionalStep' + Type = 'IdLE.Step.EmitEvent' + Condition = @{ Exists = 'Request.Context.Identity.Profile' } + } + ) +} diff --git a/tests/fixtures/workflows/resolver-missing-capability.psd1 b/tests/fixtures/workflows/resolver-missing-capability.psd1 new file mode 100644 index 00000000..6e4ae76b --- /dev/null +++ b/tests/fixtures/workflows/resolver-missing-capability.psd1 @@ -0,0 +1,12 @@ +@{ + Name = 'Resolver Missing Capability Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-no-provider.psd1 b/tests/fixtures/workflows/resolver-no-provider.psd1 new file mode 100644 index 00000000..d9ff5960 --- /dev/null +++ b/tests/fixtures/workflows/resolver-no-provider.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Resolver No Provider Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-non-allowlisted-cap.psd1 b/tests/fixtures/workflows/resolver-non-allowlisted-cap.psd1 new file mode 100644 index 00000000..0eae30e4 --- /dev/null +++ b/tests/fixtures/workflows/resolver-non-allowlisted-cap.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Resolver Non-Allow-Listed Capability Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.Grant' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-snapshot.psd1 b/tests/fixtures/workflows/resolver-snapshot.psd1 new file mode 100644 index 00000000..53a4fb60 --- /dev/null +++ b/tests/fixtures/workflows/resolver-snapshot.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Resolver Snapshot Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'snap-user' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-template.psd1 b/tests/fixtures/workflows/resolver-template.psd1 new file mode 100644 index 00000000..4eb3d5a2 --- /dev/null +++ b/tests/fixtures/workflows/resolver-template.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Resolver Template Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = '{{Request.IdentityKeys.Id}}' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-unknown-key.psd1 b/tests/fixtures/workflows/resolver-unknown-key.psd1 new file mode 100644 index 00000000..48ee6094 --- /dev/null +++ b/tests/fixtures/workflows/resolver-unknown-key.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Resolver Unknown Key Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + UnknownKey = 'bad' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-with-to-key.psd1 b/tests/fixtures/workflows/resolver-with-to-key.psd1 new file mode 100644 index 00000000..1591bdef --- /dev/null +++ b/tests/fixtures/workflows/resolver-with-to-key.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Resolver With To Key Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + To = 'Context.Identity.Entitlements' + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} From 6693e57255ca50083d4105dfb08512c6985a612e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:08:53 +0000 Subject: [PATCH 4/4] fix: auth session threading, ambiguity detection, context type conflict guard, missing-context guard Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Invoke-IdleContextResolvers.ps1 | 172 ++++++++++++++---- .../Private/Test-IdleWorkflowSchema.ps1 | 6 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 11 +- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 121 ++++++++++++ .../resolver-ambiguous-provider.psd1 | 13 ++ .../resolver-context-type-conflict.psd1 | 14 ++ .../workflows/resolver-with-auth-session.psd1 | 17 ++ 7 files changed, 320 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/workflows/resolver-ambiguous-provider.psd1 create mode 100644 tests/fixtures/workflows/resolver-context-type-conflict.psd1 create mode 100644 tests/fixtures/workflows/resolver-with-auth-session.psd1 diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index bc73a115..45f866a7 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -15,7 +15,10 @@ function Invoke-IdleContextResolvers { - Each capability writes to a fixed, predefined path under Request.Context. The output path is not user-configurable. - Provider is selected by alias when 'Provider' is specified; otherwise the first - provider that advertises the capability is used. + provider (sorted by alias) that advertises the capability is used; ambiguity when + multiple providers match causes 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 'Request.Context.*' paths. @@ -25,6 +28,7 @@ function Invoke-IdleContextResolvers { .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. @@ -96,10 +100,47 @@ function Invoke-IdleContextResolvers { $null } - $provider = Select-IdleResolverProvider -Capability $capability -ProviderAlias $providerAlias -Providers $Providers -ResolverPath $resolverPath + $resolvedProviderAlias = Select-IdleResolverProviderAlias -Capability $capability -ProviderAlias $providerAlias -Providers $Providers -ResolverPath $resolverPath + + # --- Auth session (optional) --- + # Supports With.AuthSessionName + With.AuthSessionOptions using the same pattern as steps. + $authSession = $null + $authBroker = Get-IdleAuthSessionBroker -Providers $Providers + + if ($with -is [System.Collections.IDictionary] -and $with.Contains('AuthSessionName')) { + $sessionName = [string]$with.AuthSessionName + $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') + } + + 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 --- - $result = Invoke-IdleResolverCapabilityDispatch -Capability $capability -Provider $provider -With $with -ResolverPath $resolverPath + $result = Invoke-IdleResolverCapabilityDispatch ` + -Capability $capability ` + -ProviderAlias $resolvedProviderAlias ` + -Providers $Providers ` + -With $with ` + -AuthSession $authSession ` + -ResolverPath $resolverPath # --- Write to predefined Request.Context path --- $contextSubPath = Get-IdleCapabilityContextPath -Capability $capability @@ -109,14 +150,39 @@ function Invoke-IdleContextResolvers { } } -function Select-IdleResolverProvider { +function Get-IdleAuthSessionBroker { <# .SYNOPSIS - Selects the appropriate provider for a context resolver capability. + 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, looks up that key in Providers. - Otherwise, selects the first provider that advertises the capability. + 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( @@ -146,27 +212,43 @@ function Select-IdleResolverProvider { ) } - return $Providers[$ProviderAlias] + return $ProviderAlias } - # Auto-select: find first provider advertising the capability - $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) + # 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() - foreach ($p in $providerInstances) { - if ($null -eq $p) { continue } - if (-not ($p.PSObject.Methods.Name -contains 'GetCapabilities')) { continue } + 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 } + $caps = $p.GetCapabilities() + if ($null -eq $caps) { continue } - $normalized = @(ConvertTo-IdleCapabilityList -Capabilities @($caps) -Normalize -Unique) - $normalizedCapability = ConvertTo-IdleNormalizedCapability -Capability $Capability - - if ($normalized -contains $normalizedCapability) { - return $p + $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 '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' @@ -180,7 +262,8 @@ function Invoke-IdleResolverCapabilityDispatch { .DESCRIPTION Maps the capability identifier to the appropriate provider method and invokes it - with parameters extracted from the With hashtable. + with parameters extracted from the With hashtable. Passes AuthSession to methods + that support it (backwards-compatible). #> [CmdletBinding()] param( @@ -188,19 +271,29 @@ function Invoke-IdleResolverCapabilityDispatch { [ValidateNotNullOrEmpty()] [string] $Capability, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ProviderAlias, + [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Provider, + [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)) { @@ -212,13 +305,18 @@ function Invoke-IdleResolverCapabilityDispatch { $identityKey = [string]$With.IdentityKey - if (-not ($Provider.PSObject.Methods.Name -contains 'ListEntitlements')) { + $method = $provider.PSObject.Methods['ListEntitlements'] + if ($null -eq $method) { throw [System.InvalidOperationException]::new( - "${ResolverPath}: Provider does not implement 'ListEntitlements', which is required for capability 'IdLE.Entitlement.List'." + "${ResolverPath}: Provider '$ProviderAlias' does not implement 'ListEntitlements', which is required for capability 'IdLE.Entitlement.List'." ) } - return @($Provider.ListEntitlements($identityKey)) + $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' { @@ -231,13 +329,18 @@ function Invoke-IdleResolverCapabilityDispatch { $identityKey = [string]$With.IdentityKey - if (-not ($Provider.PSObject.Methods.Name -contains 'GetIdentity')) { + $method = $provider.PSObject.Methods['GetIdentity'] + if ($null -eq $method) { throw [System.InvalidOperationException]::new( - "${ResolverPath}: Provider does not implement 'GetIdentity', which is required for capability 'IdLE.Identity.Read'." + "${ResolverPath}: Provider '$ProviderAlias' does not implement 'GetIdentity', which is required for capability 'IdLE.Identity.Read'." ) } - return $Provider.GetIdentity($identityKey) + $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'AuthSession' + if ($supportsAuthSession -and $null -ne $AuthSession) { + return $provider.GetIdentity($identityKey, $AuthSession) + } + return $provider.GetIdentity($identityKey) } default { @@ -254,8 +357,9 @@ function Set-IdleContextValue { Sets a value at a dotted path within a hashtable (the Request.Context). .DESCRIPTION - Navigates the dotted path, creating intermediate hashtables as needed, - and assigns the value at the leaf node. + 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( @@ -285,9 +389,15 @@ function Set-IdleContextValue { $seg = $segments[$idx] $existing = if ($current -is [System.Collections.IDictionary] -and $current.Contains($seg)) { $current[$seg] } else { $null } - if ($null -eq $existing -or -not ($existing -is [System.Collections.IDictionary])) { + 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] } diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 733b6ecf..12a3566d 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -178,8 +178,10 @@ function Test-IdleWorkflowSchema { # ContextResolvers are optional. If present, validate each resolver entry. if ($Workflow.ContainsKey('ContextResolvers') -and $null -ne $Workflow.ContextResolvers) { - if ($Workflow.ContextResolvers -isnot [System.Collections.IEnumerable] -or $Workflow.ContextResolvers -is [string]) { - $errors.Add("'ContextResolvers' must be an array/list of resolver hashtables.") + if ($Workflow.ContextResolvers -isnot [System.Collections.IEnumerable] -or + $Workflow.ContextResolvers -is [string] -or + $Workflow.ContextResolvers -is [hashtable]) { + $errors.Add("'ContextResolvers' must be an array/list of resolver hashtables, not a single hashtable.") } else { # 'To' is not user-configurable; each capability has a predefined output path. diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 3e55e4df..0c863142 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -70,10 +70,19 @@ function New-IdlePlanObject { # Resolvers populate Request.Context.* so that conditions can reference resolved data. $workflowContextResolvers = Get-IdlePropertyValue -Object $workflow -Name 'ContextResolvers' if ($null -ne $workflowContextResolvers -and @($workflowContextResolvers).Count -gt 0) { + # Ensure Request.Context exists and is a writable hashtable before invoking resolvers. + if ($reqProps -notcontains 'Context') { + $Request | Add-Member -MemberType NoteProperty -Name 'Context' -Value @{} -Force + } + elseif ($null -eq $Request.Context) { + $Request.Context = @{} + } + Invoke-IdleContextResolvers -Resolvers @($workflowContextResolvers) -Providers $Providers -Request $Request } # Create a data-only snapshot AFTER resolvers have run so that resolved context is captured. + # Re-read Context separately since resolvers may have added it to the request dynamically. $requestSnapshot = [pscustomobject]@{ PSTypeName = 'IdLE.LifecycleRequestSnapshot' LifecycleEvent = ConvertTo-NullIfEmptyString -Value ([string]$Request.LifecycleEvent) @@ -81,7 +90,7 @@ function New-IdlePlanObject { Actor = if ($reqProps -contains 'Actor') { ConvertTo-NullIfEmptyString -Value ([string]$Request.Actor) } else { $null } IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } Intent = if ($reqProps -contains 'Intent') { Copy-IdleDataObject -Value $Request.Intent } else { $null } - Context = if ($reqProps -contains 'Context') { Copy-IdleDataObject -Value $Request.Context } else { $null } + Context = if ($null -ne $Request.PSObject.Properties['Context']) { Copy-IdleDataObject -Value $Request.Context } else { $null } } # Create the plan object (planning artifact). diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 8ed523e5..0552b1fa 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -281,4 +281,125 @@ Describe 'New-IdlePlan - ContextResolvers' { $entitlements[0].Id | Should -Be 'tmpl-grp' } } + + Context 'Auth session threading' { + It 'passes AuthSession to ListEntitlements when provider method supports it' { + $wfPath = Join-Path $script:FixturesPath 'resolver-with-auth-session.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + # Provider that captures the auth session passed to ListEntitlements + $provider = [pscustomobject]@{ CapturedSession = $null } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Entitlement.List') + } + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey, [object]$AuthSession) + $this.CapturedSession = $AuthSession + return @(@{ Kind = 'Group'; Id = 'auth-grp' }) + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'test-token' + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + # Auth session was passed through to the provider + $provider.CapturedSession | Should -Not -BeNullOrEmpty + $entitlements = @($plan.Request.Context.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].Id | Should -Be 'auth-grp' + } + } + + Context 'Provider ambiguity detection' { + It 'fails when multiple providers support the same capability and Provider is not specified' { + $wfPath = Join-Path $script:FixturesPath 'resolver-ambiguous-provider.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $makeProvider = { + $p = [pscustomobject]@{} + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $p | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { param([string]$IdentityKey) return @() } + return $p + } + + $providers = @{ + Provider1 = & $makeProvider + Provider2 = & $makeProvider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage "*Multiple providers*disambiguate*" + } + } + + Context 'Context type conflict detection' { + It 'fails when an intermediate context node is a non-dictionary type' { + $wfPath = Join-Path $script:FixturesPath 'resolver-context-type-conflict.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + # Pre-populate Identity as a scalar string, conflicting with the predefined path + Identity = 'some-scalar-value' + } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{} + Entitlements = @(@{ Kind = 'Group'; Id = 'g1' }) + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage "*intermediate node*Identity*" + } + } + + Context 'Request.Context guard in New-IdlePlanObject' { + It 'creates Request.Context when the request has no Context property' { + $wfPath = Join-Path $script:FixturesPath 'resolver-condition.psd1' + + # Create a minimal request object without Context + $req = [pscustomobject]@{ + LifecycleEvent = 'Joiner' + CorrelationId = [System.Guid]::NewGuid().ToString() + IdentityKeys = @{ Id = 'user1' } + } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{} + Entitlements = @(@{ Kind = 'Group'; Id = 'g1' }) + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + # Should not throw even though request has no Context property + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Request.Context | Should -Not -BeNullOrEmpty + } + } } diff --git a/tests/fixtures/workflows/resolver-ambiguous-provider.psd1 b/tests/fixtures/workflows/resolver-ambiguous-provider.psd1 new file mode 100644 index 00000000..cc53f7a6 --- /dev/null +++ b/tests/fixtures/workflows/resolver-ambiguous-provider.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Resolver Ambiguous Provider Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-context-type-conflict.psd1 b/tests/fixtures/workflows/resolver-context-type-conflict.psd1 new file mode 100644 index 00000000..a558c737 --- /dev/null +++ b/tests/fixtures/workflows/resolver-context-type-conflict.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Resolver Context Type Conflict Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ IdentityKey = 'user1' } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-with-auth-session.psd1 b/tests/fixtures/workflows/resolver-with-auth-session.psd1 new file mode 100644 index 00000000..c03fc97c --- /dev/null +++ b/tests/fixtures/workflows/resolver-with-auth-session.psd1 @@ -0,0 +1,17 @@ +@{ + Name = 'Resolver Auth Session Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + Provider = 'Identity' + With = @{ + IdentityKey = 'user1' + AuthSessionName = 'TestSession' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +}