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 new file mode 100644 index 00000000..059c7423 --- /dev/null +++ b/examples/workflows/mock/joiner-with-context-resolvers.psd1 @@ -0,0 +1,59 @@ +@{ + # 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.*. + # + # 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. + # 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. + With = @{ + IdentityKey = 'user1' + } + } + ) + + Steps = @( + @{ + # Always runs - ensures the base group membership. + Name = 'EnsureBaseGroup' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ Kind = 'Group'; Id = 'all-employees' } + State = 'Present' + Provider = 'Identity' + } + } + + @{ + # 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 = @{ + 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..634bc049 --- /dev/null +++ b/src/IdLE.Core/Private/Get-IdleReadOnlyCapabilities.ps1 @@ -0,0 +1,81 @@ +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. + + 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', 'IdLE.Identity.Read') + #> + [CmdletBinding()] + [OutputType([string[]])] + param() + + 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 new file mode 100644 index 00000000..45f866a7 --- /dev/null +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -0,0 +1,406 @@ +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 + predefined path for that capability (see Get-IdleCapabilityContextPath). + + Rules enforced: + - Only capabilities in the read-only allow-list (Get-IdleReadOnlyCapabilities) may be used. + - 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 (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. + + .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 ($resolver.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$resolver.Provider)) { + [string]$resolver.Provider + } + else { + $null + } + + $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 ` + -ProviderAlias $resolvedProviderAlias ` + -Providers $Providers ` + -With $with ` + -AuthSession $authSession ` + -ResolverPath $resolverPath + + # --- Write to predefined Request.Context path --- + $contextSubPath = Get-IdleCapabilityContextPath -Capability $capability + Set-IdleContextValue -Context $Request.Context -Path $contextSubPath -Value $result + + $i++ + } +} + +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 '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 +} diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 6e37a514..12a3566d 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,51 @@ 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] -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. + $allowedResolverKeys = @('Capability', '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'.") + } + + # '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..0c863142 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,26 @@ 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) { + # 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) @@ -70,12 +90,9 @@ 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 } } - # 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..0552b1fa --- /dev/null +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -0,0 +1,405 @@ +Set-StrictMode -Version Latest + +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( + [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 = Join-Path $script:FixturesPath 'resolver-condition.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -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' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps[0].Status | Should -Be 'Planned' + + # 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) + $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 = Join-Path $script:FixturesPath 'resolver-empty-entitlements.psd1' + + $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' + } + + It 'IdLE.Identity.Read resolver populates Request.Context.Identity.Profile' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $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 + + $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' + } + } + + 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' + + { 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 = Join-Path $script:FixturesPath 'resolver-non-allowlisted-cap.psd1' + + $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 = Join-Path $script:FixturesPath 'resolver-snapshot.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'snap-user' = @{ + IdentityKey = 'snap-user' + Enabled = $true + Attributes = @{} + Entitlements = @( + @{ Kind = 'Role'; Id = 'admin' } + ) + } + } + + $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 + # 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' + } + } + + Context 'Provider auto-selection' { + It 'auto-selects provider when Provider is not specified in resolver' { + $wfPath = Join-Path $script:FixturesPath 'resolver-autoselect.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'auto-user' = @{ + IdentityKey = 'auto-user' + Enabled = $true + Attributes = @{} + Entitlements = @( + @{ Kind = 'Group'; Id = 'grp-auto' } + ) + } + } + + # 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 = Join-Path $script:FixturesPath 'resolver-no-provider.psd1' + + $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 = Join-Path $script:FixturesPath 'resolver-unknown-key.psd1' + + $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 = Join-Path $script:FixturesPath 'resolver-missing-capability.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + { New-IdlePlan -WorkflowPath $wfPath -Request $req } | + Should -Throw -ExpectedMessage "*Capability*" + } + } + + Context 'Template resolution in With' { + It 'resolves Request.IdentityKeys template in With.IdentityKey' { + $wfPath = Join-Path $script:FixturesPath 'resolver-template.psd1' + + $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' } + ) + } + } + + $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' + } + } + + 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-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-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-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-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' } + ) +} 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' } + ) +}