From e5e237d8bd2c6fb4843faef60da929d82726fcf0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:33:49 +0000 Subject: [PATCH 01/15] Initial plan From 80c9e5a48d996ca9276e53a470625a8292048d6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:58:26 +0000 Subject: [PATCH 02/15] feat: implement provider/auth-scoped context namespaces, views, Current alias, and source metadata for ContextResolvers Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/capabilities.md | 58 ++- docs/use/workflows/context-resolver.md | 182 +++++++-- .../mock/joiner-with-context-resolvers.psd1 | 21 +- .../Private/Invoke-IdleContextResolvers.ps1 | 243 +++++++++++- .../Public/Invoke-IdlePlanObject.ps1 | 49 +++ .../New-IdlePlan.ContextResolvers.Tests.ps1 | 346 ++++++++++++++++-- .../workflows/resolver-condition.psd1 | 2 +- .../resolver-current-precondition.psd1 | 24 ++ .../resolver-empty-entitlements.psd1 | 2 +- .../workflows/resolver-identity-read.psd1 | 2 +- .../resolver-invalid-auth-session.psd1 | 17 + .../resolver-invalid-provider-alias.psd1 | 16 + .../workflows/resolver-two-auth-sessions.psd1 | 25 ++ .../workflows/resolver-two-providers.psd1 | 23 ++ 14 files changed, 917 insertions(+), 93 deletions(-) create mode 100644 tests/fixtures/workflows/resolver-current-precondition.psd1 create mode 100644 tests/fixtures/workflows/resolver-invalid-auth-session.psd1 create mode 100644 tests/fixtures/workflows/resolver-invalid-provider-alias.psd1 create mode 100644 tests/fixtures/workflows/resolver-two-auth-sessions.psd1 create mode 100644 tests/fixtures/workflows/resolver-two-providers.psd1 diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 976c7f1e..98799a6c 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -132,18 +132,44 @@ Recommended provider documentation pattern: --- -## ContextResolvers: read-only capabilities and predefined Context paths +## ContextResolvers: read-only capabilities and Context namespace 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. +Each resolver writes to a **provider/auth-scoped source-of-truth path** under `Request.Context.Providers.*` and engine-defined **Views** for capabilities with aggregation semantics. The paths are not user-configurable. -| Capability | Predefined `Request.Context` path | Required `With` keys | +### Source-of-truth paths + +``` +Request.Context.Providers... +``` + +| Capability | CapabilitySubPath | Required `With` keys | |---|---|---| -| `IdLE.Entitlement.List` | `Request.Context.Identity.Entitlements` | `IdentityKey` (string) | -| `IdLE.Identity.Read` | `Request.Context.Identity.Profile` | `IdentityKey` (string) | +| `IdLE.Entitlement.List` | `Identity.Entitlements` | `IdentityKey` (string) | +| `IdLE.Identity.Read` | `Identity.Profile` | `IdentityKey` (string) | + +Where `` is `Default` when `With.AuthSessionName` is not specified. -> **Note**: `IdLE.Entitlement.List` writes an array of entitlement objects, each with properties: `Kind` (string), `Id` (string), and optionally `DisplayName` (string). To reference entitlement Ids in Conditions, use `Request.Context.Identity.Entitlements.Id`. See [Conditions - Member-Access Enumeration](../use/workflows/conditions.md#member-access-enumeration). +Examples: +- `Request.Context.Providers.Entra.Default.Identity.Entitlements` +- `Request.Context.Providers.AD.CorpAdmin.Identity.Entitlements` +- `Request.Context.Providers.Identity.Default.Identity.Profile` + +### Views (engine-defined aggregations) + +For `IdLE.Entitlement.List`, the engine additionally builds: + +| View | Path | +|---|---| +| Global (all providers merged) | `Request.Context.Views.Identity.Entitlements` | +| Provider-specific (one provider merged) | `Request.Context.Views.Providers..Identity.Entitlements` | + +> **Note**: `IdLE.Entitlement.List` writes an array of entitlement objects. Each entry includes: +> `Kind` (string), `Id` (string), and optionally `DisplayName` (string), +> plus source metadata: `SourceProvider` (string) and `SourceAuthSessionName` (string). +> To reference entitlement Ids in Conditions, use the `.Id` member-access pattern. +> See [Conditions - Member-Access Enumeration](../use/workflows/conditions.md#member-access-enumeration). ### Example @@ -155,29 +181,33 @@ ContextResolvers = @( IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Provider = 'Identity' # optional; auto-selected if omitted } - # Writes to Request.Context.Identity.Entitlements (predefined, not configurable) + # Writes to: Request.Context.Providers.Identity.Default.Identity.Entitlements + # View: Request.Context.Views.Identity.Entitlements } @{ Capability = 'IdLE.Identity.Read' With = @{ IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' } - # Writes to Request.Context.Identity.Profile (predefined, not configurable) + # Writes to: Request.Context.Providers.Identity.Default.Identity.Profile } ) ``` -Steps can then reference the resolved data in their `Condition`: +Steps can then reference the resolved data in their `Condition` using the global view (most common) or scoped paths: ```powershell -# Check if entitlements exist -Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } +# Global view: check if entitlements exist from any provider +Condition = @{ Exists = 'Request.Context.Views.Identity.Entitlements' } -# Check if a specific group Id is present +# Global view: check if a specific group Id is present across all providers Condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=Admins,OU=Groups,DC=example,DC=com' } } + +# Scoped path: check entitlements from a specific provider only +Condition = @{ Exists = 'Request.Context.Providers.Identity.Default.Identity.Entitlements' } ``` -> **Tip**: Use `$plan.Request.Context.Identity.Entitlements | Format-Table` to inspect the structure of resolved entitlements. See [Context Resolvers - Inspecting resolved context data](../use/workflows/context-resolver.md#inspecting-resolved-context-data). +> **Tip**: Use `$plan.Request.Context.Views.Identity.Entitlements | Format-Table` to inspect resolved entitlements. See [Context Resolvers - Inspecting resolved context data](../use/workflows/context-resolver.md#inspecting-resolved-context-data). diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index aa020be8..c5a6d7af 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -28,10 +28,60 @@ to rely on data that was resolved once during planning. --- -## Full Example +## Context Namespace Structure + +Each resolver writes its output to a **provider/auth-scoped source-of-truth path** and updates **engine-defined Views**. + +### Source of truth (scoped path) + +``` +Request.Context.Providers... +``` + +- `` — the provider alias from `With.Provider` (or the auto-selected alias). +- `` — `Default` when `With.AuthSessionName` is not specified; otherwise the exact name. +- `` — the capability-defined sub-path: + - `IdLE.Entitlement.List` → `Identity.Entitlements` + - `IdLE.Identity.Read` → `Identity.Profile` + +Examples: +- `Request.Context.Providers.Entra.Default.Identity.Entitlements` +- `Request.Context.Providers.Entra.CorpAdmin.Identity.Entitlements` +- `Request.Context.Providers.AD.Default.Identity.Entitlements` +- `Request.Context.Providers.Identity.Default.Identity.Profile` -A resolver entry is defined at workflow root level: +### Views (engine-defined aggregations) +For capabilities with defined view semantics, the engine builds deterministic Views after each resolver: + +| View | Path | Description | +|---|---|---| +| Global view | `Request.Context.Views.` | Merged from all providers and auth sessions. | +| Provider view | `Request.Context.Views.Providers..` | Merged for one provider across all auth sessions. | + +Currently only `IdLE.Entitlement.List` has defined view semantics. + +Examples: +- `Request.Context.Views.Identity.Entitlements` — entitlements from **all** providers and sessions merged +- `Request.Context.Views.Providers.Entra.Identity.Entitlements` — Entra entitlements only + +### Step-relative Current alias (execution-time only) + +During **precondition** evaluation (execution time), you may use `Request.Context.Current.*` to refer +to the scoped context of the step's own provider and auth session: + +``` +Request.Context.Current. +``` + +Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). + +> **Restriction:** `Request.Context.Current.*` MUST NOT be used in plan-time `Condition` fields. +> It is only valid in `Precondition` and other execution-time evaluations. + +--- + +## Full Example ```powershell @{ @@ -46,13 +96,17 @@ A resolver entry is defined at workflow root level: Provider = 'Identity' # optional; auto-selected if omitted AuthSessionName = 'Tier0' # optional; requires AuthSessionBroker in Providers } + # Writes to: Request.Context.Providers.Identity.Tier0.Identity.Profile } @{ Capability = 'IdLE.Entitlement.List' With = @{ IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' + Provider = 'Identity' } + # Writes to: Request.Context.Providers.Identity.Default.Identity.Entitlements + # View: Request.Context.Views.Identity.Entitlements } ) @@ -62,8 +116,9 @@ A resolver entry is defined at workflow root level: Name = 'Disable only if identity exists' Type = 'IdLE.Step.DisableIdentity' + # Reference the scoped source-of-truth path: Condition = @{ - Exists = 'Request.Context.Identity.Profile' + Exists = 'Request.Context.Providers.Identity.Tier0.Identity.Profile' } } @@ -72,7 +127,7 @@ A resolver entry is defined at workflow root level: Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Disabled identity {{Request.Context.Identity.Profile.DisplayName}}' + Message = 'Disabled identity {{Request.Context.Providers.Identity.Tier0.Identity.Profile.DisplayName}}' } } ) @@ -90,63 +145,100 @@ A resolver entry is defined at workflow root level: | `With` key | Type | Required | Description | |---|---|---|---| | `IdentityKey` | `string` | Per capability | Required by `IdLE.Identity.Read` and `IdLE.Entitlement.List`. | - | `Provider` | `string` | No | Provider alias. If omitted, IdLE auto-selects a provider advertising the capability. Ambiguity (multiple providers matching) is a fail-fast error. | - | `AuthSessionName` | `string` | No | Named auth session to acquire via `AuthSessionBroker`. Requires an `AuthSessionBroker` entry in `Providers`. | + | `Provider` | `string` | No | Provider alias. If omitted, IdLE auto-selects a provider advertising the capability. Ambiguity (multiple providers matching) is a fail-fast error. Also used to determine `` in the scoped path. | + | `AuthSessionName` | `string` | No | Named auth session key. Becomes `` in the scoped path. If omitted, `Default` is used. Requires an `AuthSessionBroker` entry in `Providers`. Must be a valid path segment (no dots). | | `AuthSessionOptions` | `hashtable` | No | Options passed to `AuthSessionBroker.AcquireAuthSession`. Must be a hashtable. ScriptBlocks are rejected. | -Output paths are predefined and cannot be changed. - --- ## Common Patterns -### Resolve once, use everywhere - -Resolve identity or entitlements once and reuse the result in: +### Use the global View for "don't care about source" -- Conditions -- Preconditions -- Templates - -Example: +The most common pattern for entitlements: check or reference entitlements regardless of which provider returned them: ```powershell -Condition = @{ Exists = 'Request.Context.Identity.Profile' } +# In a Condition: +Condition = @{ Exists = 'Request.Context.Views.Identity.Entitlements' } -DisplayName = '{{Request.Context.Identity.Profile.DisplayName}}' +# In a NotContains check (member-access enumeration across all providers): +Condition = @{ + NotContains = @{ + Path = 'Request.Context.Views.Identity.Entitlements.Id' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } +} ``` -### Guard destructive steps +### Use scoped paths for provider-specific checks -Only perform destructive actions if identity exists: +When you need to check entitlements only from a specific provider: ```powershell Condition = @{ - Exists = 'Request.Context.Identity.Profile' + Exists = 'Request.Context.Providers.Entra.Default.Identity.Entitlements' } ``` -### Entitlement snapshot usage +### Multi-provider entitlements (no collision) -Resolve entitlements once: +Use the same capability for multiple providers. Results are kept isolated: ```powershell ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' } - } + @{ Capability = 'IdLE.Entitlement.List'; With = @{ IdentityKey = 'user1'; Provider = 'Entra' } } + @{ Capability = 'IdLE.Entitlement.List'; With = @{ IdentityKey = 'user1'; Provider = 'AD' } } ) +# Result: Providers.Entra.Default.Identity.Entitlements (Entra-specific) +# Providers.AD.Default.Identity.Entitlements (AD-specific) +# Views.Identity.Entitlements (merged, both providers) +``` + +### Step-relative precondition using Current + +Use `Request.Context.Current.*` in a step's `Precondition` to check the scoped context +for that step's own provider without hard-coding the provider alias: + +```powershell +@{ + Name = 'EnsureEntitlement' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + Provider = 'Entra' + IdentityKey = '{{Request.IdentityKeys.Id}}' + Entitlement = @{ Kind = 'Group'; Id = 'sg-all-staff' } + State = 'Present' + } + # Current resolves to Providers.Entra.Default at execution time (derived from With.Provider) + Precondition = @{ Exists = 'Request.Context.Current.Identity.Entitlements' } +} ``` -Then guard on availability: +### Guard destructive steps + +Only perform destructive actions if identity exists: ```powershell -Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } +Condition = @{ + Exists = 'Request.Context.Providers.Identity.Default.Identity.Profile' +} ``` --- +## Entitlement Source Metadata + +Every entitlement entry in a resolved list includes source metadata automatically added by the engine: + +| Property | Description | +|---|---| +| `SourceProvider` | The provider alias that returned this entitlement. | +| `SourceAuthSessionName` | The auth session key used (`Default` if no session was specified). | + +This enables auditing and per-source filtering when working with merged views. + +--- + ## Troubleshooting ### Resolver not executed @@ -167,10 +259,18 @@ Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } - Verify required `With` parameters. - Ensure template placeholders resolve correctly. +- Remember: scoped path uses `Providers...`. + Views are only available for `IdLE.Entitlement.List`. ### Type conflict in context path - A resolver cannot overwrite an existing path with incompatible type. +- Pre-existing context keys like `Providers` or `Views` must be hashtables. + +### Invalid provider alias or AuthSessionName + +- Provider alias and `AuthSessionName` must be valid path segments: `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$` +- Dots (`.`) are not allowed as they are used as path separators. ### Inspecting resolved context data @@ -184,38 +284,42 @@ $plan = New-IdlePlan -WorkflowPath ./workflow.psd1 -Request $req -Providers $pro # View the entire context structure $plan.Request.Context | ConvertTo-Json -Depth 5 -# View specific resolved data -$plan.Request.Context.Identity.Entitlements | ConvertTo-Json -Depth 2 +# View scoped entitlements for a specific provider +$plan.Request.Context.Providers.Identity.Default.Identity.Entitlements | ConvertTo-Json -Depth 2 + +# View the global merged view +$plan.Request.Context.Views.Identity.Entitlements | ConvertTo-Json -Depth 2 ``` **Method 2: Use Format-Table for quick inspection** ```powershell -# After planning, inspect entitlements structure -$plan.Request.Context.Identity.Entitlements | Format-Table -AutoSize +# After planning, inspect entitlements structure (global view) +$plan.Request.Context.Views.Identity.Entitlements | Format-Table -AutoSize ``` **Method 3: Access individual properties** ```powershell # Check if entitlements are objects with properties -$plan.Request.Context.Identity.Entitlements[0] | Get-Member -$plan.Request.Context.Identity.Entitlements[0].Id -$plan.Request.Context.Identity.Entitlements[0].DisplayName +$plan.Request.Context.Views.Identity.Entitlements[0] | Get-Member +$plan.Request.Context.Views.Identity.Entitlements[0].Id +$plan.Request.Context.Views.Identity.Entitlements[0].SourceProvider ``` **Using discovered structure in Conditions** -Once you know the structure (e.g., entitlements are objects with `Kind`, `Id`, `DisplayName`), use member-access enumeration in your condition paths: +Once you know the structure, use member-access enumeration in your condition paths: ```powershell -# Extract Id values from all entitlement objects +# Extract Id values from all entitlement objects (global view) Condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } ``` See [Conditions - Member-Access Enumeration](./conditions.md#member-access-enumeration) for details. + diff --git a/examples/workflows/mock/joiner-with-context-resolvers.psd1 b/examples/workflows/mock/joiner-with-context-resolvers.psd1 index 7fd25a47..e4a8f526 100644 --- a/examples/workflows/mock/joiner-with-context-resolvers.psd1 +++ b/examples/workflows/mock/joiner-with-context-resolvers.psd1 @@ -2,21 +2,26 @@ # 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.*. + # capabilities to fetch data and write it under provider/auth-scoped namespaces plus + # engine-defined Views: # - # 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 + # Source of truth (scoped): Request.Context.Providers... + # e.g. IdLE.Entitlement.List -> Request.Context.Providers.Identity.Default.Identity.Entitlements + # e.g. IdLE.Identity.Read -> Request.Context.Providers.Identity.Default.Identity.Profile + # + # Global view (merged from all providers): Request.Context.Views.Identity.Entitlements + # Provider view (merged for one provider): Request.Context.Views.Providers.Identity.Identity.Entitlements Name = 'Joiner - ContextResolvers Demo' LifecycleEvent = 'Joiner' # Planning-time resolvers: run before condition evaluation. - # Each capability has a predefined output path under Request.Context.* + # Each capability writes to a provider/auth-scoped path and updates deterministic Views. ContextResolvers = @( @{ # Fetch current entitlements for the identity being onboarded. - # Writes to Request.Context.Identity.Entitlements (predefined). + # Source of truth: Request.Context.Providers.Identity.Default.Identity.Entitlements + # Global view: Request.Context.Views.Identity.Entitlements Capability = 'IdLE.Entitlement.List' # Resolver inputs. @@ -44,11 +49,11 @@ @{ # Runs only when entitlements were successfully pre-resolved by the ContextResolver. - # References the predefined context path for IdLE.Entitlement.List. + # Uses the global View path (all providers merged), which is the most common reference pattern. Name = 'EmitContextAvailable' Type = 'IdLE.Step.EmitEvent' Condition = @{ - Exists = 'Request.Context.Identity.Entitlements' + Exists = 'Request.Context.Views.Identity.Entitlements' } With = @{ Message = 'Entitlement context was pre-resolved by ContextResolvers and is available for planning.' diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index 00cadea8..6bc8de37 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -7,13 +7,22 @@ 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 - predefined path for that capability (see Get-IdleCapabilityContextPath). + provider capability and writing the result under Request.Context using a + provider/auth-scoped namespace as the source of truth, with engine-defined + Views for common aggregation patterns. Rules enforced: - Only capabilities in the read-only allow-list (Get-IdleReadOnlyCapabilities) may be used. - - Each capability writes to a fixed, predefined path under Request.Context. - The output path is not user-configurable. + - Results are written to the provider/auth-scoped path: + Request.Context.Providers... + where AuthSessionKey is 'Default' when With.AuthSessionName is not specified. + - Engine-defined Views are (re)built deterministically after each resolver: + Request.Context.Views. (global: all providers/sessions) + Request.Context.Views.Providers..<...> (provider: all sessions) + View semantics are capability-specific; currently only IdLE.Entitlement.List has views. + - For IdLE.Entitlement.List, each entry is annotated with SourceProvider and + SourceAuthSessionName to enable auditing and source-specific filtering. + - Provider alias and AuthSessionKey must be valid context path segments. - Provider is selected by alias when 'With.Provider' is specified. When 'With.Provider' is omitted, auto-selection only succeeds if exactly one provider advertises the capability; zero matches or multiple matches both cause a fail-fast error. @@ -21,7 +30,7 @@ function Invoke-IdleContextResolvers { 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. + can reference the resolved data via scoped paths or Views. .PARAMETER Resolvers Array of resolver hashtables from the workflow definition. May be null or empty. @@ -102,18 +111,26 @@ function Invoke-IdleContextResolvers { $resolvedProviderAlias = Select-IdleResolverProviderAlias -Capability $capability -ProviderAlias $providerAlias -Providers $Providers -ResolverPath $resolverPath + # --- Validate provider alias as a context path segment --- + Assert-IdleContextPathSegment -Value $resolvedProviderAlias -Label 'Provider alias' -ResolverPath $resolverPath + # --- Auth session (optional) --- # Supports With.AuthSessionName + With.AuthSessionOptions using the same pattern as steps. $authSession = $null $authBroker = Get-IdleAuthSessionBroker -Providers $Providers + $authSessionKey = 'Default' - if ($with -is [System.Collections.IDictionary] -and $with.Contains('AuthSessionName')) { + if ($with -is [System.Collections.IDictionary] -and $with.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$with.AuthSessionName)) { $sessionName = [string]$with.AuthSessionName + $authSessionKey = $sessionName $sessionOptions = if ($with.Contains('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } if ($null -ne $sessionOptions -and $sessionOptions -isnot [hashtable]) { throw [System.ArgumentException]::new("$resolverPath 'With.AuthSessionOptions' must be a hashtable.", 'Workflow') } + # --- Validate auth session key as a context path segment --- + Assert-IdleContextPathSegment -Value $authSessionKey -Label 'AuthSessionName' -ResolverPath $resolverPath + if ($null -eq $authBroker) { throw [System.ArgumentException]::new( "$resolverPath specifies With.AuthSessionName '$sessionName' but no AuthSessionBroker was found in Providers.", @@ -142,14 +159,224 @@ function Invoke-IdleContextResolvers { -AuthSession $authSession ` -ResolverPath $resolverPath - # --- Write to predefined Request.Context path --- + # --- Annotate entitlement results with source metadata --- + if ($capability -eq 'IdLE.Entitlement.List') { + $result = @(Add-IdleEntitlementSourceMetadata -Entitlements @($result) -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey) + } + + # --- Write to provider/auth-scoped path (source of truth) --- + # Path: Providers... $contextSubPath = Get-IdleCapabilityContextPath -Capability $capability - Set-IdleContextValue -Context $Request.Context -Path $contextSubPath -Value $result + $scopedPath = "Providers.$resolvedProviderAlias.$authSessionKey.$contextSubPath" + Set-IdleContextValue -Context $Request.Context -Path $scopedPath -Value $result + + # --- Rebuild deterministic Views for capabilities with defined view semantics --- + Build-IdleContextResolverViews -Context $Request.Context -Capability $capability -CapabilitySubPath $contextSubPath $i++ } } +function Assert-IdleContextPathSegment { + <# + .SYNOPSIS + Validates that a value is a valid context path segment (no dots, valid identifier characters). + + .DESCRIPTION + Context path segments are used to build hierarchical paths in Request.Context. + They must not contain dots (path separators) and must match a safe identifier pattern. + + .PARAMETER Value + The value to validate. + + .PARAMETER Label + Human-readable label for error messages (e.g., 'Provider alias', 'AuthSessionName'). + + .PARAMETER ResolverPath + The resolver path (e.g., 'ContextResolvers[0]') for error context. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Value, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Label, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ResolverPath + ) + + if ($Value -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$') { + throw [System.ArgumentException]::new( + ('{0}: {1} ''{2}'' is not a valid context path segment. Must match pattern ''^[A-Za-z0-9][A-Za-z0-9_-]{{0,63}}$'' (no dots, start with alphanumeric, max 64 chars).' -f $ResolverPath, $Label, $Value), + 'Workflow' + ) + } +} + +function Add-IdleEntitlementSourceMetadata { + <# + .SYNOPSIS + Annotates each entitlement entry with SourceProvider and SourceAuthSessionName metadata. + + .DESCRIPTION + Ensures every entitlement returned by IdLE.Entitlement.List resolvers carries source + information to support auditing, per-provider filtering, and merged view semantics. + + .PARAMETER Entitlements + Array of entitlement objects (hashtables or PSCustomObjects). + + .PARAMETER SourceProvider + The provider alias that produced these entitlements. + + .PARAMETER SourceAuthSessionName + The auth session key used ('Default' if no explicit session was specified). + + .OUTPUTS + Object[] + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [object[]] $Entitlements, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceProvider, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceAuthSessionName + ) + + if ($null -eq $Entitlements -or $Entitlements.Count -eq 0) { + return @() + } + + $result = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Entitlements) { + if ($null -eq $item) { + $result.Add($null) + continue + } + + if ($item -is [System.Collections.IDictionary]) { + $enriched = @{} + foreach ($key in $item.Keys) { $enriched[$key] = $item[$key] } + $enriched['SourceProvider'] = $SourceProvider + $enriched['SourceAuthSessionName'] = $SourceAuthSessionName + $result.Add($enriched) + } + else { + # PSCustomObject or other reference type — add properties non-destructively + $item | Add-Member -MemberType NoteProperty -Name 'SourceProvider' -Value $SourceProvider -Force + $item | Add-Member -MemberType NoteProperty -Name 'SourceAuthSessionName' -Value $SourceAuthSessionName -Force + $result.Add($item) + } + } + + return @($result) +} + +function Build-IdleContextResolverViews { + <# + .SYNOPSIS + Rebuilds engine-defined Views in Request.Context for capabilities with defined view semantics. + + .DESCRIPTION + Views are deterministic, engine-defined aggregations of scoped resolver outputs. + Called after each resolver execution to keep views current. + + Currently only IdLE.Entitlement.List has defined view semantics: + - Global view: Request.Context.Views.Identity.Entitlements + (merge of all provider/session scoped lists, sorted by ProviderAlias then AuthSessionKey) + - Provider view: Request.Context.Views.Providers..Identity.Entitlements + (merge of all session scoped lists for that provider) + + No view is defined for IdLE.Identity.Read; results are only in the scoped path. + + .PARAMETER Context + The Request.Context hashtable to update. + + .PARAMETER Capability + The capability identifier (e.g., 'IdLE.Entitlement.List'). + + .PARAMETER CapabilitySubPath + The capability sub-path (e.g., 'Identity.Entitlements'). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Context, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Capability, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $CapabilitySubPath + ) + + # Only IdLE.Entitlement.List has defined view semantics. + if ($Capability -ne 'IdLE.Entitlement.List') { + return + } + + $globalList = [System.Collections.Generic.List[object]]::new() + $perProviderLists = @{} + + $providersNode = if ($Context.Contains('Providers')) { $Context['Providers'] } else { $null } + if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary]) { + # Stable ordering: sorted by ProviderAlias, then AuthSessionKey + $sortedProviders = @($providersNode.Keys | Sort-Object) + foreach ($providerAlias in $sortedProviders) { + $providerNode = $providersNode[$providerAlias] + if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } + + $sortedAuthKeys = @($providerNode.Keys | Sort-Object) + foreach ($authKey in $sortedAuthKeys) { + $authNode = $providerNode[$authKey] + if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } + + # Navigate the CapabilitySubPath within the auth node + $items = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath + if ($null -eq $items) { continue } + + $itemArray = @($items) + if ($itemArray.Count -eq 0) { continue } + + foreach ($item in $itemArray) { + $globalList.Add($item) + } + + if (-not $perProviderLists.Contains($providerAlias)) { + $perProviderLists[$providerAlias] = [System.Collections.Generic.List[object]]::new() + } + foreach ($item in $itemArray) { + $perProviderLists[$providerAlias].Add($item) + } + } + } + } + + # Global view: Request.Context.Views. + Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) + + # Provider views: Request.Context.Views.Providers.. + foreach ($providerAlias in $perProviderLists.Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) + } +} + function Get-IdleAuthSessionBroker { <# .SYNOPSIS diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 2374cca9..e83548a8 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -324,6 +324,46 @@ function Invoke-IdlePlanObject { # Continue = emits events but skips the step and continues to the next step. # Non-IDictionary precondition nodes are treated as precondition failures (fail closed). $stepPrecondition = Get-IdlePropertyValue -Object $step -Name 'Precondition' + + # Set Request.Context.Current alias for step-relative path resolution in preconditions. + # Resolved from Step.With.Provider + Step.With.AuthSessionName (or 'Default'). + # Scoped to the precondition evaluation; cleaned up immediately after. + $currentContextSet = $false + if ($null -ne $stepPrecondition -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) { + $currentProviderAlias = $null + $currentAuthKey = 'Default' + if ($null -ne $stepWith) { + if ($stepWith -is [System.Collections.IDictionary]) { + if ($stepWith.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['Provider'])) { + $currentProviderAlias = [string]$stepWith['Provider'] + } + if ($stepWith.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['AuthSessionName'])) { + $currentAuthKey = [string]$stepWith['AuthSessionName'] + } + } + elseif ($stepWith.PSObject.Properties.Name -contains 'Provider') { + $pVal = $stepWith.Provider + if (-not [string]::IsNullOrWhiteSpace([string]$pVal)) { $currentProviderAlias = [string]$pVal } + $aVal = if ($stepWith.PSObject.Properties.Name -contains 'AuthSessionName') { $stepWith.AuthSessionName } else { $null } + if (-not [string]::IsNullOrWhiteSpace([string]$aVal)) { $currentAuthKey = [string]$aVal } + } + } + + $currentContextValue = $null + if (-not [string]::IsNullOrWhiteSpace($currentProviderAlias)) { + $providersNode = if ($request.Context.Contains('Providers')) { $request.Context['Providers'] } else { $null } + if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary] -and $providersNode.Contains($currentProviderAlias)) { + $providerNode = $providersNode[$currentProviderAlias] + if ($null -ne $providerNode -and $providerNode -is [System.Collections.IDictionary] -and $providerNode.Contains($currentAuthKey)) { + $currentContextValue = $providerNode[$currentAuthKey] + } + } + } + + $request.Context['Current'] = $currentContextValue + $currentContextSet = $true + } + if ($null -ne $stepPrecondition) { $preconditionPassed = $true if ($stepPrecondition -isnot [System.Collections.IDictionary]) { @@ -398,6 +438,10 @@ function Invoke-IdlePlanObject { Attempts = 0 } $i++ + # Clean up the Current alias before continuing to the next step. + if ($currentContextSet -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) { + $null = $request.Context.Remove('Current') + } continue } else { @@ -428,6 +472,11 @@ function Invoke-IdlePlanObject { # Stop processing if a precondition failure was handled above. if ($failed -or $blocked) { break } + # Clean up the Current alias after precondition evaluation. + if ($currentContextSet -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) { + $null = $request.Context.Remove('Current') + } + $context.EventSink.WriteEvent( 'StepStarted', "Step '$stepName' started.", diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 0552b1fa..e56c9993 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -61,12 +61,17 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan | Should -Not -BeNullOrEmpty $plan.Steps[0].Status | Should -Be 'Planned' - # Snapshot captures resolved context (predefined path: Identity.Entitlements) + # Results written to scoped path: Providers..Default. $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' + $plan.Request.Context.Providers | Should -Not -BeNullOrEmpty + $scopedEntitlements = @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements) + $scopedEntitlements.Count | Should -Be 1 + $scopedEntitlements[0].Id | Should -Be 'g1' + + # Global view is also populated: Views. + $viewEntitlements = @($plan.Request.Context.Views.Identity.Entitlements) + $viewEntitlements.Count | Should -Be 1 + $viewEntitlements[0].Id | Should -Be 'g1' } It 'step is NotApplicable when resolver returns empty entitlements and condition requires them' { @@ -97,7 +102,7 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan.Steps[0].Status | Should -Be 'NotApplicable' } - It 'IdLE.Identity.Read resolver populates Request.Context.Identity.Profile' { + It 'IdLE.Identity.Read resolver populates scoped path Providers.Identity.Default.Identity.Profile' { $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } @@ -119,10 +124,10 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $plan | Should -Not -BeNullOrEmpty - # Predefined path for IdLE.Identity.Read is Identity.Profile + # Scoped path for IdLE.Identity.Read: Providers..Default.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' + $plan.Request.Context.Providers.Identity.Default.Identity.Profile | Should -Not -BeNullOrEmpty + $plan.Request.Context.Providers.Identity.Default.Identity.Profile.IdentityKey | Should -Be 'user1' } } @@ -149,7 +154,7 @@ Describe 'New-IdlePlan - ContextResolvers' { } Context 'Resolver output captured in plan snapshot' { - It 'plan.Request.Context contains the resolved value after planning' { + It 'plan.Request.Context contains the resolved value after planning (scoped path and view)' { $wfPath = Join-Path $script:FixturesPath 'resolver-snapshot.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' @@ -174,11 +179,18 @@ Describe 'New-IdlePlan - ContextResolvers' { $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' + + # Scoped path: Providers..Default.Identity.Entitlements + $scoped = @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements) + $scoped.Count | Should -Be 1 + $scoped[0].Kind | Should -Be 'Role' + $scoped[0].Id | Should -Be 'admin' + + # Global view: Views.Identity.Entitlements + $view = @($plan.Request.Context.Views.Identity.Entitlements) + $view.Count | Should -Be 1 + $view[0].Kind | Should -Be 'Role' + $view[0].Id | Should -Be 'admin' } } @@ -208,9 +220,15 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers $plan | Should -Not -BeNullOrEmpty - $entitlements = @($plan.Request.Context.Identity.Entitlements) + # Auto-selected provider alias is 'IdentityProvider' + $entitlements = @($plan.Request.Context.Providers.IdentityProvider.Default.Identity.Entitlements) $entitlements.Count | Should -Be 1 $entitlements[0].Id | Should -Be 'grp-auto' + + # Global view is also populated + $viewEntitlements = @($plan.Request.Context.Views.Identity.Entitlements) + $viewEntitlements.Count | Should -Be 1 + $viewEntitlements[0].Id | Should -Be 'grp-auto' } It 'fails when no provider supports the capability and Provider is not specified' { @@ -276,7 +294,7 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - $entitlements = @($plan.Request.Context.Identity.Entitlements) + $entitlements = @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements) $entitlements.Count | Should -Be 1 $entitlements[0].Id | Should -Be 'tmpl-grp' } @@ -312,9 +330,16 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan | Should -Not -BeNullOrEmpty # Auth session was passed through to the provider $provider.CapturedSession | Should -Not -BeNullOrEmpty - $entitlements = @($plan.Request.Context.Identity.Entitlements) + + # Results written to scoped path using the AuthSessionName as key: Providers.Identity.TestSession.Identity.Entitlements + $entitlements = @($plan.Request.Context.Providers.Identity.TestSession.Identity.Entitlements) $entitlements.Count | Should -Be 1 $entitlements[0].Id | Should -Be 'auth-grp' + + # Global view also populated + $viewEntitlements = @($plan.Request.Context.Views.Identity.Entitlements) + $viewEntitlements.Count | Should -Be 1 + $viewEntitlements[0].Id | Should -Be 'auth-grp' } } @@ -347,8 +372,8 @@ Describe 'New-IdlePlan - ContextResolvers' { $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' + # Pre-populate Providers as a scalar string, conflicting with the new scoped path + Providers = 'some-scalar-value' } $provider = New-IdleMockIdentityProvider -InitialStore @{ @@ -366,7 +391,7 @@ Describe 'New-IdlePlan - ContextResolvers' { } { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | - Should -Throw -ExpectedMessage "*intermediate node*Identity*" + Should -Throw -ExpectedMessage "*intermediate node*Providers*" } } @@ -402,4 +427,283 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan.Request.Context | Should -Not -BeNullOrEmpty } } + + Context 'Provider/Auth-scoped namespace (source of truth)' { + It 'two providers writing the same capability produce independent scoped paths without collision' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $makeProvider = { + param([string]$GroupId) + # Store GroupId on the object so the ScriptMethod can access it via $this + $p = [pscustomobject]@{ FixtureGroupId = $GroupId } + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $p | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey) + return @(@{ Kind = 'Group'; Id = $this.FixtureGroupId }) + } + return $p + } + + $providers = @{ + Entra = & $makeProvider -GroupId 'entra-grp' + AD = & $makeProvider -GroupId 'ad-grp' + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + + # Each provider writes to its own scoped path without overwriting the other + $entraEntitlements = @($plan.Request.Context.Providers.Entra.Default.Identity.Entitlements) + $entraEntitlements.Count | Should -Be 1 + $entraEntitlements[0].Id | Should -Be 'entra-grp' + + $adEntitlements = @($plan.Request.Context.Providers.AD.Default.Identity.Entitlements) + $adEntitlements.Count | Should -Be 1 + $adEntitlements[0].Id | Should -Be 'ad-grp' + } + + It 'multiple auth sessions for same provider produce independent scoped paths' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-auth-sessions.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{} + $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) + return @(@{ Kind = 'Group'; Id = "grp-from-$AuthSession" }) + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'token-corp' + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([string]$Name, $Options) + if ($Name -eq 'Corp') { return 'token-corp' } + if ($Name -eq 'Tier0') { return 'token-tier0' } + return 'token-default' + } -Force + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + + # Each auth session writes to its own scoped path + $corpEntitlements = @($plan.Request.Context.Providers.Identity.Corp.Identity.Entitlements) + $corpEntitlements.Count | Should -Be 1 + $corpEntitlements[0].Id | Should -Be 'grp-from-token-corp' + + $tier0Entitlements = @($plan.Request.Context.Providers.Identity.Tier0.Identity.Entitlements) + $tier0Entitlements.Count | Should -Be 1 + $tier0Entitlements[0].Id | Should -Be 'grp-from-token-tier0' + } + } + + Context 'Deterministic Views for IdLE.Entitlement.List' { + It 'global view merges entitlements from all providers sorted by provider alias then auth session key' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $makeProvider = { + param([string]$GroupId) + $p = [pscustomobject]@{ FixtureGroupId = $GroupId } + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $p | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey) + return @(@{ Kind = 'Group'; Id = $this.FixtureGroupId }) + } + return $p + } + + $providers = @{ + Entra = & $makeProvider -GroupId 'entra-grp' + AD = & $makeProvider -GroupId 'ad-grp' + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Global view contains both providers' entitlements (sorted: AD before Entra alphabetically) + $globalView = @($plan.Request.Context.Views.Identity.Entitlements) + $globalView.Count | Should -Be 2 + $ids = $globalView | ForEach-Object { $_.Id } + $ids | Should -Contain 'entra-grp' + $ids | Should -Contain 'ad-grp' + } + + It 'provider view contains only entitlements for that provider' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $makeProvider = { + param([string]$GroupId) + $p = [pscustomobject]@{ FixtureGroupId = $GroupId } + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $p | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey) + return @(@{ Kind = 'Group'; Id = $this.FixtureGroupId }) + } + return $p + } + + $providers = @{ + Entra = & $makeProvider -GroupId 'entra-grp' + AD = & $makeProvider -GroupId 'ad-grp' + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Provider view for Entra contains only Entra entitlements + $entraView = @($plan.Request.Context.Views.Providers.Entra.Identity.Entitlements) + $entraView.Count | Should -Be 1 + $entraView[0].Id | Should -Be 'entra-grp' + + # Provider view for AD contains only AD entitlements + $adView = @($plan.Request.Context.Views.Providers.AD.Identity.Entitlements) + $adView.Count | Should -Be 1 + $adView[0].Id | Should -Be 'ad-grp' + } + + It 'entitlement entries include SourceProvider and SourceAuthSessionName metadata' { + $wfPath = Join-Path $script:FixturesPath 'resolver-with-auth-session.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{} + $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) + return @(@{ Kind = 'Group'; Id = 'src-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 + + $entitlements = @($plan.Request.Context.Providers.Identity.TestSession.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].SourceProvider | Should -Be 'Identity' + $entitlements[0].SourceAuthSessionName | Should -Be 'TestSession' + } + + It 'entitlement entries without explicit auth session have SourceAuthSessionName Default' { + $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 + + $entitlements = @($plan.Request.Context.Providers.Identity.Default.Identity.Entitlements) + $entitlements.Count | Should -Be 1 + $entitlements[0].SourceProvider | Should -Be 'Identity' + $entitlements[0].SourceAuthSessionName | Should -Be 'Default' + } + } + + Context 'Fail-fast on invalid path segments' { + It 'fails when provider alias contains a dot (invalid path segment)' { + $wfPath = Join-Path $script:FixturesPath 'resolver-invalid-provider-alias.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + # Provider with a dot in its alias - not a valid path segment + $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 @() } + + $providers = @{ + 'Invalid.Alias' = $p + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage "*not a valid context path segment*" + } + + It 'fails when AuthSessionName contains a dot (invalid path segment)' { + $wfPath = Join-Path $script:FixturesPath 'resolver-invalid-auth-session.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{} + $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) + return @() + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'test-token' + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage "*not a valid context path segment*" + } + } + + Context 'Request.Context.Current alias (execution-time preconditions)' { + It 'Current resolves to the step provider/auth scoped context during precondition evaluation' { + $wfPath = Join-Path $script:FixturesPath 'resolver-current-precondition.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $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 + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result | Should -Not -BeNullOrEmpty + # Step should have executed (precondition passed via Current path) + $stepResult = $result.Steps | Where-Object { $_.Name -eq 'CurrentPreconditionStep' } + $stepResult | Should -Not -BeNullOrEmpty + $stepResult.Status | Should -Be 'Completed' + } + } } diff --git a/tests/fixtures/workflows/resolver-condition.psd1 b/tests/fixtures/workflows/resolver-condition.psd1 index 38c4a352..711bba35 100644 --- a/tests/fixtures/workflows/resolver-condition.psd1 +++ b/tests/fixtures/workflows/resolver-condition.psd1 @@ -14,7 +14,7 @@ @{ Name = 'ConditionalStep' Type = 'IdLE.Step.EmitEvent' - Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + Condition = @{ Exists = 'Request.Context.Views.Identity.Entitlements' } } ) } diff --git a/tests/fixtures/workflows/resolver-current-precondition.psd1 b/tests/fixtures/workflows/resolver-current-precondition.psd1 new file mode 100644 index 00000000..9464e5a9 --- /dev/null +++ b/tests/fixtures/workflows/resolver-current-precondition.psd1 @@ -0,0 +1,24 @@ +@{ + Name = 'Resolver Current Precondition Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + } + } + ) + Steps = @( + @{ + Name = 'CurrentPreconditionStep' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Provider = 'Identity' + } + # Precondition uses Current alias: resolves to Providers.Identity.Default at execution time + Precondition = @{ Exists = 'Request.Context.Current.Identity.Entitlements' } + } + ) +} diff --git a/tests/fixtures/workflows/resolver-empty-entitlements.psd1 b/tests/fixtures/workflows/resolver-empty-entitlements.psd1 index 2bf94940..cd14f6bd 100644 --- a/tests/fixtures/workflows/resolver-empty-entitlements.psd1 +++ b/tests/fixtures/workflows/resolver-empty-entitlements.psd1 @@ -14,7 +14,7 @@ @{ Name = 'NeedsEntitlements' Type = 'IdLE.Step.EmitEvent' - Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + Condition = @{ Exists = 'Request.Context.Views.Identity.Entitlements' } } ) } diff --git a/tests/fixtures/workflows/resolver-identity-read.psd1 b/tests/fixtures/workflows/resolver-identity-read.psd1 index f55cecc2..de385bc3 100644 --- a/tests/fixtures/workflows/resolver-identity-read.psd1 +++ b/tests/fixtures/workflows/resolver-identity-read.psd1 @@ -14,7 +14,7 @@ @{ Name = 'ConditionalStep' Type = 'IdLE.Step.EmitEvent' - Condition = @{ Exists = 'Request.Context.Identity.Profile' } + Condition = @{ Exists = 'Request.Context.Providers.Identity.Default.Identity.Profile' } } ) } diff --git a/tests/fixtures/workflows/resolver-invalid-auth-session.psd1 b/tests/fixtures/workflows/resolver-invalid-auth-session.psd1 new file mode 100644 index 00000000..c4ab29e5 --- /dev/null +++ b/tests/fixtures/workflows/resolver-invalid-auth-session.psd1 @@ -0,0 +1,17 @@ +@{ + Name = 'Resolver Invalid Auth Session Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Invalid.Session.Name' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-invalid-provider-alias.psd1 b/tests/fixtures/workflows/resolver-invalid-provider-alias.psd1 new file mode 100644 index 00000000..5da50d75 --- /dev/null +++ b/tests/fixtures/workflows/resolver-invalid-provider-alias.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Resolver Invalid Provider Alias Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Invalid.Alias' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 b/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 new file mode 100644 index 00000000..f73f8caa --- /dev/null +++ b/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 @@ -0,0 +1,25 @@ +@{ + Name = 'Resolver Two Auth Sessions Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Corp' + } + } + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Tier0' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-two-providers.psd1 b/tests/fixtures/workflows/resolver-two-providers.psd1 new file mode 100644 index 00000000..522b4190 --- /dev/null +++ b/tests/fixtures/workflows/resolver-two-providers.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Resolver Two Providers Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Entra' + } + } + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'AD' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} From 8b81ee1c06fb70a7650e31d1fe8923de1b90a0f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:00:42 +0000 Subject: [PATCH 03/15] fixup: address code review - improve path segment error message, filter null entitlement entries Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index 6bc8de37..ff6f32f5 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -212,7 +212,7 @@ function Assert-IdleContextPathSegment { if ($Value -notmatch '^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$') { throw [System.ArgumentException]::new( - ('{0}: {1} ''{2}'' is not a valid context path segment. Must match pattern ''^[A-Za-z0-9][A-Za-z0-9_-]{{0,63}}$'' (no dots, start with alphanumeric, max 64 chars).' -f $ResolverPath, $Label, $Value), + ('{0}: {1} ''{2}'' is not a valid context path segment. Must start with alphanumeric, followed by alphanumeric, hyphens, or underscores (max 64 chars total, no dots allowed).' -f $ResolverPath, $Label, $Value), 'Workflow' ) } @@ -263,7 +263,7 @@ function Add-IdleEntitlementSourceMetadata { $result = [System.Collections.Generic.List[object]]::new() foreach ($item in $Entitlements) { if ($null -eq $item) { - $result.Add($null) + # Skip null entries; provider implementations must not return null items in an entitlement list. continue } From 21d98b4d59c3bac362e5b48336eb174e3d8e6efe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:42:07 +0000 Subject: [PATCH 04/15] feat: add session and provider+session views to Build-IdleContextResolverViews Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/capabilities.md | 6 +- docs/use/workflows/context-resolver.md | 12 ++- .../Private/Invoke-IdleContextResolvers.ps1 | 57 +++++++++++--- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 78 +++++++++++++++++++ 4 files changed, 135 insertions(+), 18 deletions(-) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 98799a6c..00dc0b58 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -162,8 +162,10 @@ For `IdLE.Entitlement.List`, the engine additionally builds: | View | Path | |---|---| -| Global (all providers merged) | `Request.Context.Views.Identity.Entitlements` | -| Provider-specific (one provider merged) | `Request.Context.Views.Providers..Identity.Entitlements` | +| All providers, all sessions | `Request.Context.Views.Identity.Entitlements` | +| One provider, all sessions | `Request.Context.Views.Providers..Identity.Entitlements` | +| All providers, one session | `Request.Context.Views.Sessions..Identity.Entitlements` | +| One provider, one session | `Request.Context.Views.Providers..Sessions..Identity.Entitlements` | > **Note**: `IdLE.Entitlement.List` writes an array of entitlement objects. Each entry includes: > `Kind` (string), `Id` (string), and optionally `DisplayName` (string), diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index c5a6d7af..5b0e8ee3 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -56,14 +56,18 @@ For capabilities with defined view semantics, the engine builds deterministic Vi | View | Path | Description | |---|---|---| -| Global view | `Request.Context.Views.` | Merged from all providers and auth sessions. | -| Provider view | `Request.Context.Views.Providers..` | Merged for one provider across all auth sessions. | +| All providers, all sessions | `Request.Context.Views.` | Merged from all providers and all auth sessions. | +| One provider, all sessions | `Request.Context.Views.Providers..` | Merged for one provider across all its auth sessions. | +| All providers, one session | `Request.Context.Views.Sessions..` | Merged across all providers that ran with the given auth session key. | +| One provider, one session | `Request.Context.Views.Providers..Sessions..` | Exactly one provider + one auth session. | Currently only `IdLE.Entitlement.List` has defined view semantics. Examples: -- `Request.Context.Views.Identity.Entitlements` — entitlements from **all** providers and sessions merged -- `Request.Context.Views.Providers.Entra.Identity.Entitlements` — Entra entitlements only +- `Request.Context.Views.Identity.Entitlements` — all providers, all sessions merged +- `Request.Context.Views.Providers.Entra.Identity.Entitlements` — Entra only, all sessions +- `Request.Context.Views.Sessions.Default.Identity.Entitlements` — all providers, Default session only +- `Request.Context.Views.Providers.Entra.Sessions.CorpAdmin.Identity.Entitlements` — Entra + CorpAdmin session only ### Step-relative Current alias (execution-time only) diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index ff6f32f5..c2523a57 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -295,11 +295,16 @@ function Build-IdleContextResolverViews { Called after each resolver execution to keep views current. Currently only IdLE.Entitlement.List has defined view semantics: - - Global view: Request.Context.Views.Identity.Entitlements - (merge of all provider/session scoped lists, sorted by ProviderAlias then AuthSessionKey) - - Provider view: Request.Context.Views.Providers..Identity.Entitlements - (merge of all session scoped lists for that provider) - + - Global view (all providers, all sessions): + Request.Context.Views.Identity.Entitlements + - Provider view (one provider, all sessions): + Request.Context.Views.Providers..Identity.Entitlements + - Session view (all providers, one session): + Request.Context.Views.Sessions..Identity.Entitlements + - Provider+Session view (one provider, one session): + Request.Context.Views.Providers..Sessions..Identity.Entitlements + + All views use stable ordering (sorted by ProviderAlias then AuthSessionKey). No view is defined for IdLE.Identity.Read; results are only in the scoped path. .PARAMETER Context @@ -333,6 +338,8 @@ function Build-IdleContextResolverViews { $globalList = [System.Collections.Generic.List[object]]::new() $perProviderLists = @{} + $perSessionLists = @{} + $perProviderSessionLists = @{} $providersNode = if ($Context.Contains('Providers')) { $Context['Providers'] } else { $null } if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary]) { @@ -354,27 +361,53 @@ function Build-IdleContextResolverViews { $itemArray = @($items) if ($itemArray.Count -eq 0) { continue } - foreach ($item in $itemArray) { - $globalList.Add($item) - } + # Global list: all providers, all sessions + foreach ($item in $itemArray) { $globalList.Add($item) } + # Per-provider list (all sessions for this provider) if (-not $perProviderLists.Contains($providerAlias)) { $perProviderLists[$providerAlias] = [System.Collections.Generic.List[object]]::new() } - foreach ($item in $itemArray) { - $perProviderLists[$providerAlias].Add($item) + foreach ($item in $itemArray) { $perProviderLists[$providerAlias].Add($item) } + + # Per-session list (all providers for this auth session key) + if (-not $perSessionLists.Contains($authKey)) { + $perSessionLists[$authKey] = [System.Collections.Generic.List[object]]::new() + } + foreach ($item in $itemArray) { $perSessionLists[$authKey].Add($item) } + + # Per-provider+session list (one provider, one session) + # Use a nested hashtable keyed by provider alias to avoid delimiter collision issues. + if (-not $perProviderSessionLists.Contains($providerAlias)) { + $perProviderSessionLists[$providerAlias] = @{} + } + if (-not $perProviderSessionLists[$providerAlias].Contains($authKey)) { + $perProviderSessionLists[$providerAlias][$authKey] = [System.Collections.Generic.List[object]]::new() } + foreach ($item in $itemArray) { $perProviderSessionLists[$providerAlias][$authKey].Add($item) } } } } - # Global view: Request.Context.Views. + # Global view: all providers, all sessions → Request.Context.Views. Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) - # Provider views: Request.Context.Views.Providers.. + # Provider views: one provider, all sessions → Request.Context.Views.Providers.. foreach ($providerAlias in $perProviderLists.Keys) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) } + + # Session views: all providers, one session → Request.Context.Views.Sessions.. + foreach ($authKey in $perSessionLists.Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Sessions.$authKey.$CapabilitySubPath" -Value @($perSessionLists[$authKey]) + } + + # Provider+Session views: one provider, one session → Request.Context.Views.Providers..Sessions.. + foreach ($pAlias in $perProviderSessionLists.Keys) { + foreach ($aKey in $perProviderSessionLists[$pAlias].Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value @($perProviderSessionLists[$pAlias][$aKey]) + } + } } function Get-IdleAuthSessionBroker { diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index e56c9993..b14b47f0 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -629,6 +629,84 @@ Describe 'New-IdlePlan - ContextResolvers' { $entitlements[0].SourceProvider | Should -Be 'Identity' $entitlements[0].SourceAuthSessionName | Should -Be 'Default' } + + It 'session view (all providers, one auth session) contains entitlements from all providers using that session key' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-auth-sessions.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{} + $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) + return @(@{ Kind = 'Group'; Id = "grp-from-$AuthSession" }) + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'token-corp' + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([string]$Name, $Options) + if ($Name -eq 'Corp') { return 'token-corp' } + if ($Name -eq 'Tier0') { return 'token-tier0' } + return 'token-default' + } -Force + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Session view for Corp: all providers that ran with the Corp session + $corpView = @($plan.Request.Context.Views.Sessions.Corp.Identity.Entitlements) + $corpView.Count | Should -Be 1 + $corpView[0].Id | Should -Be 'grp-from-token-corp' + + # Session view for Tier0: all providers that ran with the Tier0 session + $tier0View = @($plan.Request.Context.Views.Sessions.Tier0.Identity.Entitlements) + $tier0View.Count | Should -Be 1 + $tier0View[0].Id | Should -Be 'grp-from-token-tier0' + } + + It 'provider+session view (one provider, one auth session) contains only that exact combination' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-auth-sessions.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{} + $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) + return @(@{ Kind = 'Group'; Id = "grp-from-$AuthSession" }) + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'token-corp' + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([string]$Name, $Options) + if ($Name -eq 'Corp') { return 'token-corp' } + if ($Name -eq 'Tier0') { return 'token-tier0' } + return 'token-default' + } -Force + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Provider+Session view: Identity provider + Corp session only + $identityCorpView = @($plan.Request.Context.Views.Providers.Identity.Sessions.Corp.Identity.Entitlements) + $identityCorpView.Count | Should -Be 1 + $identityCorpView[0].Id | Should -Be 'grp-from-token-corp' + + # Provider+Session view: Identity provider + Tier0 session only + $identityTier0View = @($plan.Request.Context.Views.Providers.Identity.Sessions.Tier0.Identity.Entitlements) + $identityTier0View.Count | Should -Be 1 + $identityTier0View[0].Id | Should -Be 'grp-from-token-tier0' + } } Context 'Fail-fast on invalid path segments' { From c4766ca35674f99b935d5a9b4025d3488e4161d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:03:59 +0000 Subject: [PATCH 05/15] feat: add IdLE.Identity.Read profile views and source metadata (all four scopes) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/capabilities.md | 15 +- docs/use/workflows/context-resolver.md | 18 +- .../Private/Invoke-IdleContextResolvers.ps1 | 195 +++++++++++++++--- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 167 +++++++++++++++ ...olver-identity-read-two-auth-sessions.psd1 | 25 +++ .../resolver-identity-read-two-providers.psd1 | 23 +++ 6 files changed, 412 insertions(+), 31 deletions(-) create mode 100644 tests/fixtures/workflows/resolver-identity-read-two-auth-sessions.psd1 create mode 100644 tests/fixtures/workflows/resolver-identity-read-two-providers.psd1 diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 00dc0b58..7301c8cc 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -158,7 +158,7 @@ Examples: ### Views (engine-defined aggregations) -For `IdLE.Entitlement.List`, the engine additionally builds: +For `IdLE.Entitlement.List`, the engine additionally builds (list merge — all entries preserved): | View | Path | |---|---| @@ -173,6 +173,19 @@ For `IdLE.Entitlement.List`, the engine additionally builds: > To reference entitlement Ids in Conditions, use the `.Id` member-access pattern. > See [Conditions - Member-Access Enumeration](../use/workflows/conditions.md#member-access-enumeration). +For `IdLE.Identity.Read`, the engine additionally builds (single object — last writer wins, sorted by provider alias asc then auth key asc): + +| View | Path | +|---|---| +| All providers, all sessions | `Request.Context.Views.Identity.Profile` | +| One provider, all sessions | `Request.Context.Views.Providers..Identity.Profile` | +| All providers, one session | `Request.Context.Views.Sessions..Identity.Profile` | +| One provider, one session | `Request.Context.Views.Providers..Sessions..Identity.Profile` | + +> **Note**: `IdLE.Identity.Read` writes a single profile object, annotated with `SourceProvider` and `SourceAuthSessionName`. +> When multiple providers or sessions contribute to a view scope, the profile from the last entry +> in sort order (provider alias ascending, then auth key ascending) is used. + ### Example ```powershell diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index 5b0e8ee3..8aa80b9e 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -56,19 +56,27 @@ For capabilities with defined view semantics, the engine builds deterministic Vi | View | Path | Description | |---|---|---| -| All providers, all sessions | `Request.Context.Views.` | Merged from all providers and all auth sessions. | -| One provider, all sessions | `Request.Context.Views.Providers..` | Merged for one provider across all its auth sessions. | -| All providers, one session | `Request.Context.Views.Sessions..` | Merged across all providers that ran with the given auth session key. | +| All providers, all sessions | `Request.Context.Views.` | Aggregated from all providers and all auth sessions. | +| One provider, all sessions | `Request.Context.Views.Providers..` | Aggregated for one provider across all its auth sessions. | +| All providers, one session | `Request.Context.Views.Sessions..` | Aggregated across all providers that ran with the given auth session key. | | One provider, one session | `Request.Context.Views.Providers..Sessions..` | Exactly one provider + one auth session. | -Currently only `IdLE.Entitlement.List` has defined view semantics. +**`IdLE.Entitlement.List`** — list merge (all entries preserved across all contributing providers/sessions): -Examples: - `Request.Context.Views.Identity.Entitlements` — all providers, all sessions merged - `Request.Context.Views.Providers.Entra.Identity.Entitlements` — Entra only, all sessions - `Request.Context.Views.Sessions.Default.Identity.Entitlements` — all providers, Default session only - `Request.Context.Views.Providers.Entra.Sessions.CorpAdmin.Identity.Entitlements` — Entra + CorpAdmin session only +**`IdLE.Identity.Read`** — single-object view (last writer wins with deterministic sort order: provider alias asc, then auth key asc): + +- `Request.Context.Views.Identity.Profile` — last profile across all providers and sessions +- `Request.Context.Views.Providers.Entra.Identity.Profile` — last profile from Entra (across all sessions) +- `Request.Context.Views.Sessions.Default.Identity.Profile` — last profile from any provider using the Default session +- `Request.Context.Views.Providers.Entra.Sessions.CorpAdmin.Identity.Profile` — exact profile for Entra + CorpAdmin + +All profile and entitlement entries include `SourceProvider` and `SourceAuthSessionName` metadata for auditing. + ### Step-relative Current alias (execution-time only) During **precondition** evaluation (execution time), you may use `Request.Context.Current.*` to refer diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index c2523a57..7340ab50 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -19,9 +19,13 @@ function Invoke-IdleContextResolvers { - Engine-defined Views are (re)built deterministically after each resolver: Request.Context.Views. (global: all providers/sessions) Request.Context.Views.Providers..<...> (provider: all sessions) - View semantics are capability-specific; currently only IdLE.Entitlement.List has views. + Request.Context.Views.Sessions..<...> (session: all providers) + Request.Context.Views.Providers..Sessions..<...> (exact) + View semantics are capability-specific; both IdLE.Entitlement.List and IdLE.Identity.Read have views. - For IdLE.Entitlement.List, each entry is annotated with SourceProvider and SourceAuthSessionName to enable auditing and source-specific filtering. + - For IdLE.Identity.Read, the profile object is annotated with SourceProvider and + SourceAuthSessionName; multi-source views use last-write-wins with deterministic sort order. - Provider alias and AuthSessionKey must be valid context path segments. - Provider is selected by alias when 'With.Provider' is specified. When 'With.Provider' is omitted, auto-selection only succeeds if exactly one provider advertises the @@ -164,6 +168,11 @@ function Invoke-IdleContextResolvers { $result = @(Add-IdleEntitlementSourceMetadata -Entitlements @($result) -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey) } + # --- Annotate profile result with source metadata --- + if ($capability -eq 'IdLE.Identity.Read') { + $result = Add-IdleProfileSourceMetadata -Profile $result -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey + } + # --- Write to provider/auth-scoped path (source of truth) --- # Path: Providers... $contextSubPath = Get-IdleCapabilityContextPath -Capability $capability @@ -285,6 +294,63 @@ function Add-IdleEntitlementSourceMetadata { return @($result) } +function Add-IdleProfileSourceMetadata { + <# + .SYNOPSIS + Annotates a profile object with SourceProvider and SourceAuthSessionName metadata. + + .DESCRIPTION + Ensures every profile returned by IdLE.Identity.Read resolvers carries source + information to support auditing and view semantics (including identifying which + provider/session a profile came from in aggregated views). + + .PARAMETER Profile + The profile object (hashtable or PSCustomObject) to annotate. + + .PARAMETER SourceProvider + The provider alias that produced the profile. + + .PARAMETER SourceAuthSessionName + The auth session key used ('Default' if no explicit session was specified). + + .OUTPUTS + Object + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter()] + [AllowNull()] + [object] $Profile, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceProvider, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceAuthSessionName + ) + + if ($null -eq $Profile) { + return $null + } + + if ($Profile -is [System.Collections.IDictionary]) { + $enriched = @{} + foreach ($key in $Profile.Keys) { $enriched[$key] = $Profile[$key] } + $enriched['SourceProvider'] = $SourceProvider + $enriched['SourceAuthSessionName'] = $SourceAuthSessionName + return $enriched + } + else { + # PSCustomObject or other reference type — add properties non-destructively + $Profile | Add-Member -MemberType NoteProperty -Name 'SourceProvider' -Value $SourceProvider -Force + $Profile | Add-Member -MemberType NoteProperty -Name 'SourceAuthSessionName' -Value $SourceAuthSessionName -Force + return $Profile + } +} + function Build-IdleContextResolverViews { <# .SYNOPSIS @@ -294,7 +360,7 @@ function Build-IdleContextResolverViews { Views are deterministic, engine-defined aggregations of scoped resolver outputs. Called after each resolver execution to keep views current. - Currently only IdLE.Entitlement.List has defined view semantics: + IdLE.Entitlement.List view semantics (list merge, all entries are preserved): - Global view (all providers, all sessions): Request.Context.Views.Identity.Entitlements - Provider view (one provider, all sessions): @@ -304,8 +370,19 @@ function Build-IdleContextResolverViews { - Provider+Session view (one provider, one session): Request.Context.Views.Providers..Sessions..Identity.Entitlements + IdLE.Identity.Read view semantics (single object, last writer wins, deterministic sort order): + - Global view (all providers, all sessions): + Request.Context.Views.Identity.Profile + - Provider view (one provider, all sessions): + Request.Context.Views.Providers..Identity.Profile + - Session view (all providers, one session): + Request.Context.Views.Sessions..Identity.Profile + - Provider+Session view (one provider, one session): + Request.Context.Views.Providers..Sessions..Identity.Profile + All views use stable ordering (sorted by ProviderAlias then AuthSessionKey). - No view is defined for IdLE.Identity.Read; results are only in the scoped path. + For IdLE.Identity.Read, views where multiple profiles could contribute use last-write-wins + with deterministic sort order (last provider or session alphabetically wins). .PARAMETER Context The Request.Context hashtable to update. @@ -331,18 +408,22 @@ function Build-IdleContextResolverViews { [string] $CapabilitySubPath ) - # Only IdLE.Entitlement.List has defined view semantics. - if ($Capability -ne 'IdLE.Entitlement.List') { + # Only capabilities with defined view semantics are processed. + if ($Capability -notin @('IdLE.Entitlement.List', 'IdLE.Identity.Read')) { return } - $globalList = [System.Collections.Generic.List[object]]::new() - $perProviderLists = @{} - $perSessionLists = @{} - $perProviderSessionLists = @{} - $providersNode = if ($Context.Contains('Providers')) { $Context['Providers'] } else { $null } - if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary]) { + if ($null -eq $providersNode -or -not ($providersNode -is [System.Collections.IDictionary])) { + return + } + + if ($Capability -eq 'IdLE.Entitlement.List') { + $globalList = [System.Collections.Generic.List[object]]::new() + $perProviderLists = @{} + $perSessionLists = @{} + $perProviderSessionLists = @{} + # Stable ordering: sorted by ProviderAlias, then AuthSessionKey $sortedProviders = @($providersNode.Keys | Sort-Object) foreach ($providerAlias in $sortedProviders) { @@ -387,25 +468,89 @@ function Build-IdleContextResolverViews { foreach ($item in $itemArray) { $perProviderSessionLists[$providerAlias][$authKey].Add($item) } } } - } - # Global view: all providers, all sessions → Request.Context.Views. - Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) + # Global view: all providers, all sessions → Request.Context.Views. + Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) - # Provider views: one provider, all sessions → Request.Context.Views.Providers.. - foreach ($providerAlias in $perProviderLists.Keys) { - Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) - } + # Provider views: one provider, all sessions → Request.Context.Views.Providers.. + foreach ($providerAlias in $perProviderLists.Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) + } + + # Session views: all providers, one session → Request.Context.Views.Sessions.. + foreach ($authKey in $perSessionLists.Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Sessions.$authKey.$CapabilitySubPath" -Value @($perSessionLists[$authKey]) + } - # Session views: all providers, one session → Request.Context.Views.Sessions.. - foreach ($authKey in $perSessionLists.Keys) { - Set-IdleContextValue -Context $Context -Path "Views.Sessions.$authKey.$CapabilitySubPath" -Value @($perSessionLists[$authKey]) + # Provider+Session views: one provider, one session → Request.Context.Views.Providers..Sessions.. + foreach ($pAlias in $perProviderSessionLists.Keys) { + foreach ($aKey in $perProviderSessionLists[$pAlias].Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value @($perProviderSessionLists[$pAlias][$aKey]) + } + } + return } - # Provider+Session views: one provider, one session → Request.Context.Views.Providers..Sessions.. - foreach ($pAlias in $perProviderSessionLists.Keys) { - foreach ($aKey in $perProviderSessionLists[$pAlias].Keys) { - Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value @($perProviderSessionLists[$pAlias][$aKey]) + if ($Capability -eq 'IdLE.Identity.Read') { + # Profile views use last-write-wins with deterministic (sorted) ordering. + # When multiple profiles exist for a view scope (e.g., two providers for the global view), + # the profile from the last entry in sort order (provider alias asc, auth key asc) wins. + $globalProfile = $null + $perProviderProfiles = @{} + $perSessionProfiles = @{} + $perProviderSessionProfiles = @{} + + # Stable ordering: sorted by ProviderAlias, then AuthSessionKey + $sortedProviders = @($providersNode.Keys | Sort-Object) + foreach ($providerAlias in $sortedProviders) { + $providerNode = $providersNode[$providerAlias] + if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } + + $sortedAuthKeys = @($providerNode.Keys | Sort-Object) + foreach ($authKey in $sortedAuthKeys) { + $authNode = $providerNode[$authKey] + if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } + + $profile = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath + if ($null -eq $profile) { continue } + + # Global view: last profile (sorted by provider then auth key) + $globalProfile = $profile + + # Per-provider view: last session's profile for this provider + $perProviderProfiles[$providerAlias] = $profile + + # Per-session view: last provider's profile for this session key + $perSessionProfiles[$authKey] = $profile + + # Per-provider+session view: exact profile (no ambiguity) + if (-not $perProviderSessionProfiles.Contains($providerAlias)) { + $perProviderSessionProfiles[$providerAlias] = @{} + } + $perProviderSessionProfiles[$providerAlias][$authKey] = $profile + } + } + + # Global view: all providers, all sessions → Request.Context.Views. + if ($null -ne $globalProfile) { + Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value $globalProfile + } + + # Provider views: one provider, all sessions → Request.Context.Views.Providers.. + foreach ($pAlias in $perProviderProfiles.Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.$CapabilitySubPath" -Value $perProviderProfiles[$pAlias] + } + + # Session views: all providers, one session → Request.Context.Views.Sessions.. + foreach ($aKey in $perSessionProfiles.Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Sessions.$aKey.$CapabilitySubPath" -Value $perSessionProfiles[$aKey] + } + + # Provider+Session views: one provider, one session → Request.Context.Views.Providers..Sessions.. + foreach ($pAlias in $perProviderSessionProfiles.Keys) { + foreach ($aKey in $perProviderSessionProfiles[$pAlias].Keys) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value $perProviderSessionProfiles[$pAlias][$aKey] + } } } } diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index b14b47f0..fda767dc 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -709,6 +709,173 @@ Describe 'New-IdlePlan - ContextResolvers' { } } + Context 'Deterministic Views for IdLE.Identity.Read' { + It 'profile includes SourceProvider and SourceAuthSessionName metadata' { + $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 + + $profile = $plan.Request.Context.Providers.Identity.Default.Identity.Profile + $profile.SourceProvider | Should -Be 'Identity' + $profile.SourceAuthSessionName | Should -Be 'Default' + } + + It 'global view (all providers, all sessions) contains the last profile in sort order' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $makeProvider = { + param([string]$Dept) + $p = [pscustomobject]@{ FixtureDept = $Dept } + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Identity.Read') } + $p | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param([string]$IdentityKey) + return @{ IdentityKey = $IdentityKey; Department = $this.FixtureDept } + } + return $p + } + + $providers = @{ + Entra = & $makeProvider -Dept 'Entra-IT' + HR = & $makeProvider -Dept 'HR-IT' + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Sorted: Entra < HR, so HR (last alphabetically) wins the global view + $globalProfile = $plan.Request.Context.Views.Identity.Profile + $globalProfile | Should -Not -BeNullOrEmpty + $globalProfile.Department | Should -Be 'HR-IT' + $globalProfile.SourceProvider | Should -Be 'HR' + } + + It 'provider view contains only the profile for that provider' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $makeProvider = { + param([string]$Dept) + $p = [pscustomobject]@{ FixtureDept = $Dept } + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Identity.Read') } + $p | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param([string]$IdentityKey) + return @{ IdentityKey = $IdentityKey; Department = $this.FixtureDept } + } + return $p + } + + $providers = @{ + Entra = & $makeProvider -Dept 'Entra-IT' + HR = & $makeProvider -Dept 'HR-IT' + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $entraProfile = $plan.Request.Context.Views.Providers.Entra.Identity.Profile + $entraProfile.Department | Should -Be 'Entra-IT' + $entraProfile.SourceProvider | Should -Be 'Entra' + + $hrProfile = $plan.Request.Context.Views.Providers.HR.Identity.Profile + $hrProfile.Department | Should -Be 'HR-IT' + $hrProfile.SourceProvider | Should -Be 'HR' + } + + It 'session view (all providers, one auth session) contains the profile for that session' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read-two-auth-sessions.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = [pscustomobject]@{} + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Identity.Read') } + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param([string]$IdentityKey, [object]$AuthSession) + return @{ IdentityKey = $IdentityKey; TokenUsed = "$AuthSession" } + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'token-corp' + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([string]$Name, $Options) + if ($Name -eq 'Corp') { return 'token-corp' } + if ($Name -eq 'Tier0') { return 'token-tier0' } + return 'token-default' + } -Force + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $corpView = $plan.Request.Context.Views.Sessions.Corp.Identity.Profile + $corpView.TokenUsed | Should -Be 'token-corp' + $corpView.SourceAuthSessionName | Should -Be 'Corp' + + $tier0View = $plan.Request.Context.Views.Sessions.Tier0.Identity.Profile + $tier0View.TokenUsed | Should -Be 'token-tier0' + $tier0View.SourceAuthSessionName | Should -Be 'Tier0' + } + + It 'provider+session view contains the exact profile for that provider and session' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read-two-auth-sessions.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = [pscustomobject]@{} + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Identity.Read') } + $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param([string]$IdentityKey, [object]$AuthSession) + return @{ IdentityKey = $IdentityKey; TokenUsed = "$AuthSession" } + } + + $broker = New-IdleAuthSessionBroker -AuthSessionType 'OAuth' -DefaultAuthSession 'token-corp' + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param([string]$Name, $Options) + if ($Name -eq 'Corp') { return 'token-corp' } + if ($Name -eq 'Tier0') { return 'token-tier0' } + return 'token-default' + } -Force + + $providers = @{ + Identity = $provider + AuthSessionBroker = $broker + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $identityCorpView = $plan.Request.Context.Views.Providers.Identity.Sessions.Corp.Identity.Profile + $identityCorpView.TokenUsed | Should -Be 'token-corp' + $identityCorpView.SourceProvider | Should -Be 'Identity' + $identityCorpView.SourceAuthSessionName | Should -Be 'Corp' + + $identityTier0View = $plan.Request.Context.Views.Providers.Identity.Sessions.Tier0.Identity.Profile + $identityTier0View.TokenUsed | Should -Be 'token-tier0' + $identityTier0View.SourceAuthSessionName | Should -Be 'Tier0' + } + } + Context 'Fail-fast on invalid path segments' { It 'fails when provider alias contains a dot (invalid path segment)' { $wfPath = Join-Path $script:FixturesPath 'resolver-invalid-provider-alias.psd1' diff --git a/tests/fixtures/workflows/resolver-identity-read-two-auth-sessions.psd1 b/tests/fixtures/workflows/resolver-identity-read-two-auth-sessions.psd1 new file mode 100644 index 00000000..ea979679 --- /dev/null +++ b/tests/fixtures/workflows/resolver-identity-read-two-auth-sessions.psd1 @@ -0,0 +1,25 @@ +@{ + Name = 'Resolver Identity Read Two Auth Sessions Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Identity.Read' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Corp' + } + } + @{ + Capability = 'IdLE.Identity.Read' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Tier0' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} diff --git a/tests/fixtures/workflows/resolver-identity-read-two-providers.psd1 b/tests/fixtures/workflows/resolver-identity-read-two-providers.psd1 new file mode 100644 index 00000000..2e584996 --- /dev/null +++ b/tests/fixtures/workflows/resolver-identity-read-two-providers.psd1 @@ -0,0 +1,23 @@ +@{ + Name = 'Resolver Identity Read Two Providers Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Identity.Read' + With = @{ + IdentityKey = 'user1' + Provider = 'Entra' + } + } + @{ + Capability = 'IdLE.Identity.Read' + With = @{ + IdentityKey = 'user1' + Provider = 'HR' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) +} From 7cb42de1854e9d049b4516510aa98611f0a86816 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:29:44 +0100 Subject: [PATCH 06/15] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Public/Invoke-IdlePlanObject.ps1 | 55 ++++++++++--------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index e83548a8..21bc4e9c 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -330,38 +330,43 @@ function Invoke-IdlePlanObject { # Scoped to the precondition evaluation; cleaned up immediately after. $currentContextSet = $false if ($null -ne $stepPrecondition -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) { - $currentProviderAlias = $null - $currentAuthKey = 'Default' - if ($null -ne $stepWith) { - if ($stepWith -is [System.Collections.IDictionary]) { - if ($stepWith.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['Provider'])) { - $currentProviderAlias = [string]$stepWith['Provider'] + # If the caller has already provided a Context['Current'], do not overwrite it. + $currentAlreadyPresent = $request.Context.Contains('Current') + + if (-not $currentAlreadyPresent) { + $currentProviderAlias = $null + $currentAuthKey = 'Default' + if ($null -ne $stepWith) { + if ($stepWith -is [System.Collections.IDictionary]) { + if ($stepWith.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['Provider'])) { + $currentProviderAlias = [string]$stepWith['Provider'] + } + if ($stepWith.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['AuthSessionName'])) { + $currentAuthKey = [string]$stepWith['AuthSessionName'] + } } - if ($stepWith.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['AuthSessionName'])) { - $currentAuthKey = [string]$stepWith['AuthSessionName'] + elseif ($stepWith.PSObject.Properties.Name -contains 'Provider') { + $pVal = $stepWith.Provider + if (-not [string]::IsNullOrWhiteSpace([string]$pVal)) { $currentProviderAlias = [string]$pVal } + $aVal = if ($stepWith.PSObject.Properties.Name -contains 'AuthSessionName') { $stepWith.AuthSessionName } else { $null } + if (-not [string]::IsNullOrWhiteSpace([string]$aVal)) { $currentAuthKey = [string]$aVal } } } - elseif ($stepWith.PSObject.Properties.Name -contains 'Provider') { - $pVal = $stepWith.Provider - if (-not [string]::IsNullOrWhiteSpace([string]$pVal)) { $currentProviderAlias = [string]$pVal } - $aVal = if ($stepWith.PSObject.Properties.Name -contains 'AuthSessionName') { $stepWith.AuthSessionName } else { $null } - if (-not [string]::IsNullOrWhiteSpace([string]$aVal)) { $currentAuthKey = [string]$aVal } - } - } - $currentContextValue = $null - if (-not [string]::IsNullOrWhiteSpace($currentProviderAlias)) { - $providersNode = if ($request.Context.Contains('Providers')) { $request.Context['Providers'] } else { $null } - if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary] -and $providersNode.Contains($currentProviderAlias)) { - $providerNode = $providersNode[$currentProviderAlias] - if ($null -ne $providerNode -and $providerNode -is [System.Collections.IDictionary] -and $providerNode.Contains($currentAuthKey)) { - $currentContextValue = $providerNode[$currentAuthKey] + $currentContextValue = $null + if (-not [string]::IsNullOrWhiteSpace($currentProviderAlias)) { + $providersNode = if ($request.Context.Contains('Providers')) { $request.Context['Providers'] } else { $null } + if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary] -and $providersNode.Contains($currentProviderAlias)) { + $providerNode = $providersNode[$currentProviderAlias] + if ($null -ne $providerNode -and $providerNode -is [System.Collections.IDictionary] -and $providerNode.Contains($currentAuthKey)) { + $currentContextValue = $providerNode[$currentAuthKey] + } } } - } - $request.Context['Current'] = $currentContextValue - $currentContextSet = $true + $request.Context['Current'] = $currentContextValue + $currentContextSet = $true + } } if ($null -ne $stepPrecondition) { From 49c18308950f4359245196aa72f39703ffa42297 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:45:39 +0000 Subject: [PATCH 07/15] fix: stale views in Build-IdleContextResolverViews; fix Current alias cleanup on Fail/Blocked Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Invoke-IdleContextResolvers.ps1 | 112 ++++++++--------- .../Public/Invoke-IdlePlanObject.ps1 | 5 + .../Invoke-IdlePlan.Preconditions.Tests.ps1 | 60 +++++++++ .../New-IdlePlan.ContextResolvers.Tests.ps1 | 114 ++++++++++++++++++ .../current-cleanup-blocked.psd1 | 28 +++++ .../preconditions/current-cleanup-fail.psd1 | 28 +++++ 6 files changed, 287 insertions(+), 60 deletions(-) create mode 100644 tests/fixtures/workflows/preconditions/current-cleanup-blocked.psd1 create mode 100644 tests/fixtures/workflows/preconditions/current-cleanup-fail.psd1 diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index 7340ab50..cd75d7d4 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -430,61 +430,53 @@ function Build-IdleContextResolverViews { $providerNode = $providersNode[$providerAlias] if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } + # Always initialize tracking for this provider so stale views are overwritten even when empty. + if (-not $perProviderLists.Contains($providerAlias)) { + $perProviderLists[$providerAlias] = [System.Collections.Generic.List[object]]::new() + } + if (-not $perProviderSessionLists.Contains($providerAlias)) { + $perProviderSessionLists[$providerAlias] = @{} + } + $sortedAuthKeys = @($providerNode.Keys | Sort-Object) foreach ($authKey in $sortedAuthKeys) { $authNode = $providerNode[$authKey] if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } - # Navigate the CapabilitySubPath within the auth node - $items = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath - if ($null -eq $items) { continue } - - $itemArray = @($items) - if ($itemArray.Count -eq 0) { continue } - - # Global list: all providers, all sessions - foreach ($item in $itemArray) { $globalList.Add($item) } - - # Per-provider list (all sessions for this provider) - if (-not $perProviderLists.Contains($providerAlias)) { - $perProviderLists[$providerAlias] = [System.Collections.Generic.List[object]]::new() - } - foreach ($item in $itemArray) { $perProviderLists[$providerAlias].Add($item) } - - # Per-session list (all providers for this auth session key) + # Always initialize tracking for this session so stale views are overwritten even when empty. if (-not $perSessionLists.Contains($authKey)) { $perSessionLists[$authKey] = [System.Collections.Generic.List[object]]::new() } - foreach ($item in $itemArray) { $perSessionLists[$authKey].Add($item) } - - # Per-provider+session list (one provider, one session) - # Use a nested hashtable keyed by provider alias to avoid delimiter collision issues. - if (-not $perProviderSessionLists.Contains($providerAlias)) { - $perProviderSessionLists[$providerAlias] = @{} - } if (-not $perProviderSessionLists[$providerAlias].Contains($authKey)) { $perProviderSessionLists[$providerAlias][$authKey] = [System.Collections.Generic.List[object]]::new() } - foreach ($item in $itemArray) { $perProviderSessionLists[$providerAlias][$authKey].Add($item) } + + # Navigate the CapabilitySubPath within the auth node; null means not yet populated. + $items = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath + if ($null -eq $items) { continue } + + foreach ($item in @($items)) { + $globalList.Add($item) + $perProviderLists[$providerAlias].Add($item) + $perSessionLists[$authKey].Add($item) + $perProviderSessionLists[$providerAlias][$authKey].Add($item) + } } } - # Global view: all providers, all sessions → Request.Context.Views. + # Always write all views (including empty arrays) to ensure stale data from prior runs is cleared. Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) - # Provider views: one provider, all sessions → Request.Context.Views.Providers.. - foreach ($providerAlias in $perProviderLists.Keys) { + foreach ($providerAlias in ($perProviderLists.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) } - # Session views: all providers, one session → Request.Context.Views.Sessions.. - foreach ($authKey in $perSessionLists.Keys) { + foreach ($authKey in ($perSessionLists.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Sessions.$authKey.$CapabilitySubPath" -Value @($perSessionLists[$authKey]) } - # Provider+Session views: one provider, one session → Request.Context.Views.Providers..Sessions.. - foreach ($pAlias in $perProviderSessionLists.Keys) { - foreach ($aKey in $perProviderSessionLists[$pAlias].Keys) { + foreach ($pAlias in ($perProviderSessionLists.Keys | Sort-Object)) { + foreach ($aKey in ($perProviderSessionLists[$pAlias].Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value @($perProviderSessionLists[$pAlias][$aKey]) } } @@ -492,9 +484,9 @@ function Build-IdleContextResolverViews { } if ($Capability -eq 'IdLE.Identity.Read') { - # Profile views use last-write-wins with deterministic (sorted) ordering. - # When multiple profiles exist for a view scope (e.g., two providers for the global view), - # the profile from the last entry in sort order (provider alias asc, auth key asc) wins. + # Profile views use last-non-null-wins with deterministic (sorted) ordering. + # When multiple profiles exist for a view scope, the last non-null profile in sort order wins. + # All views (including null) are always written to clear stale data from prior runs. $globalProfile = $null $perProviderProfiles = @{} $perSessionProfiles = @{} @@ -506,49 +498,49 @@ function Build-IdleContextResolverViews { $providerNode = $providersNode[$providerAlias] if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } + # Always initialize tracking for this provider so stale views are overwritten even when null. + if (-not $perProviderProfiles.Contains($providerAlias)) { + $perProviderProfiles[$providerAlias] = $null + } + if (-not $perProviderSessionProfiles.Contains($providerAlias)) { + $perProviderSessionProfiles[$providerAlias] = @{} + } + $sortedAuthKeys = @($providerNode.Keys | Sort-Object) foreach ($authKey in $sortedAuthKeys) { $authNode = $providerNode[$authKey] if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } - $profile = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath - if ($null -eq $profile) { continue } - - # Global view: last profile (sorted by provider then auth key) - $globalProfile = $profile + # Always initialize tracking for this session so stale views are overwritten even when null. + if (-not $perSessionProfiles.Contains($authKey)) { + $perSessionProfiles[$authKey] = $null + } - # Per-provider view: last session's profile for this provider - $perProviderProfiles[$providerAlias] = $profile + $profile = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath - # Per-session view: last provider's profile for this session key - $perSessionProfiles[$authKey] = $profile + # Aggregated views use last-non-null-wins semantics. + if ($null -ne $profile) { $globalProfile = $profile } + if ($null -ne $profile) { $perProviderProfiles[$providerAlias] = $profile } + if ($null -ne $profile) { $perSessionProfiles[$authKey] = $profile } - # Per-provider+session view: exact profile (no ambiguity) - if (-not $perProviderSessionProfiles.Contains($providerAlias)) { - $perProviderSessionProfiles[$providerAlias] = @{} - } + # Per-provider+session view: always write exact value (even null). $perProviderSessionProfiles[$providerAlias][$authKey] = $profile } } - # Global view: all providers, all sessions → Request.Context.Views. - if ($null -ne $globalProfile) { - Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value $globalProfile - } + # Always write all views (including null) to ensure stale data from prior runs is cleared. + Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value $globalProfile - # Provider views: one provider, all sessions → Request.Context.Views.Providers.. - foreach ($pAlias in $perProviderProfiles.Keys) { + foreach ($pAlias in ($perProviderProfiles.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.$CapabilitySubPath" -Value $perProviderProfiles[$pAlias] } - # Session views: all providers, one session → Request.Context.Views.Sessions.. - foreach ($aKey in $perSessionProfiles.Keys) { + foreach ($aKey in ($perSessionProfiles.Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Sessions.$aKey.$CapabilitySubPath" -Value $perSessionProfiles[$aKey] } - # Provider+Session views: one provider, one session → Request.Context.Views.Providers..Sessions.. - foreach ($pAlias in $perProviderSessionProfiles.Keys) { - foreach ($aKey in $perProviderSessionProfiles[$pAlias].Keys) { + foreach ($pAlias in ($perProviderSessionProfiles.Keys | Sort-Object)) { + foreach ($aKey in ($perProviderSessionProfiles[$pAlias].Keys | Sort-Object)) { Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value $perProviderSessionProfiles[$pAlias][$aKey] } } diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 21bc4e9c..0d137808 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -470,6 +470,11 @@ function Invoke-IdlePlanObject { ) } + # Clean up the Current alias before exiting the step loop. + if ($currentContextSet -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) { + $null = $request.Context.Remove('Current') + } + break } } diff --git a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 index 5bee17cc..c739ef0d 100644 --- a/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Preconditions.Tests.ps1 @@ -393,5 +393,65 @@ Describe 'Invoke-IdlePlan - Runtime Preconditions' { { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*unresolved condition path*' } } + + Context 'Request.Context.Current alias cleanup on Fail/Blocked' { + It 'removes Context.Current after precondition failure with OnPreconditionFalse=Blocked' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'current-cleanup-blocked.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.CurrentCleanupTest' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.CurrentCleanupTest') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Blocked' + # Current alias must be cleaned up even when precondition blocks the step. + $plan.Request.Context.Contains('Current') | Should -BeFalse + } + + It 'removes Context.Current after precondition failure with OnPreconditionFalse=Fail' { + $wfPath = Join-Path -Path $script:FixturesPath -ChildPath 'current-cleanup-fail.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.CurrentCleanupTest' = 'Invoke-IdlePreconditionTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.CurrentCleanupTest') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + # Current alias must be cleaned up even when precondition causes a failure. + $plan.Request.Context.Contains('Current') | Should -BeFalse + } + } } diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index fda767dc..5c94dfc8 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -921,6 +921,120 @@ Describe 'New-IdlePlan - ContextResolvers' { } } + Context 'View stale-data regression (empty and null results)' { + It 'entitlement global view is an empty array when the resolver returns no items' { + $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 + + # Global view must be written as empty array, not absent. + # Use Contains() because -BeNullOrEmpty also matches @() (empty array). + $plan.Request.Context.Views.Identity.Contains('Entitlements') | Should -BeTrue -Because 'global view key must be present even when empty' + @($plan.Request.Context.Views.Identity.Entitlements).Count | Should -Be 0 + + # Per-provider view must also be written. + $plan.Request.Context.Views.Providers.Identity.Identity.Contains('Entitlements') | Should -BeTrue -Because 'provider view key must be present even when empty' + @($plan.Request.Context.Views.Providers.Identity.Identity.Entitlements).Count | Should -Be 0 + } + + It 'entitlement provider view is written as empty array when that provider returns no items' { + $wfPath = Join-Path $script:FixturesPath 'resolver-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $entraProvider = [pscustomobject]@{} + $entraProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $entraProvider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey) + return @(@{ Kind = 'Group'; Id = 'grp-entra' }) + } + + $adProvider = [pscustomobject]@{} + $adProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $adProvider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey) + return @() # AD returns no entitlements + } + + $providers = @{ + Entra = $entraProvider + AD = $adProvider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Global view has Entra's entitlement + @($plan.Request.Context.Views.Identity.Entitlements).Count | Should -Be 1 + + # AD provider view must be written (as empty array), not absent. + # Use Contains() because -BeNullOrEmpty also matches @() (empty array). + $plan.Request.Context.Views.Providers.AD.Identity.Contains('Entitlements') | Should -BeTrue -Because 'AD provider view key must be present even when empty' + @($plan.Request.Context.Views.Providers.AD.Identity.Entitlements).Count | Should -Be 0 + + # AD default session provider+session view must also be written. + $plan.Request.Context.Views.Providers.AD.Sessions.Default.Identity.Contains('Entitlements') | Should -BeTrue -Because 'AD provider+session view key must be present even when empty' + @($plan.Request.Context.Views.Providers.AD.Sessions.Default.Identity.Entitlements).Count | Should -Be 0 + } + + It 'profile provider view is written as null when that provider returns no profile' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read-two-providers.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $makeProvider = { + param([object]$ProfileToReturn) + $p = [pscustomobject]@{ ProfileData = $ProfileToReturn } + $p | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Identity.Read') } + $p | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { + param([string]$IdentityKey) + return $this.ProfileData + } + return $p + } + + $providers = @{ + Entra = & $makeProvider -ProfileToReturn @{ IdentityKey = 'user1'; Source = 'Entra' } + HR = & $makeProvider -ProfileToReturn $null # HR finds no profile + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + # Global view: last-non-null wins → Entra wins (HR returned null) + $plan.Request.Context.Views.Identity.Profile | Should -Not -BeNullOrEmpty + $plan.Request.Context.Views.Identity.Profile.Source | Should -Be 'Entra' + + # HR provider view must be present as null (not absent) + $hrViewPresent = $plan.Request.Context.Views.Contains('Providers') -and + $plan.Request.Context.Views.Providers.Contains('HR') + $hrViewPresent | Should -BeTrue -Because 'HR provider view node must be present' + $plan.Request.Context.Views.Providers.HR.Identity.Profile | Should -BeNullOrEmpty + + # HR provider+session view must be present as null + $hrSessionViewPresent = $plan.Request.Context.Views.Providers.HR.Contains('Sessions') -and + $plan.Request.Context.Views.Providers.HR.Sessions.Contains('Default') + $hrSessionViewPresent | Should -BeTrue -Because 'HR provider+session view node must be present' + $plan.Request.Context.Views.Providers.HR.Sessions.Default.Identity.Profile | Should -BeNullOrEmpty + } + } + Context 'Request.Context.Current alias (execution-time preconditions)' { It 'Current resolves to the step provider/auth scoped context during precondition evaluation' { $wfPath = Join-Path $script:FixturesPath 'resolver-current-precondition.psd1' diff --git a/tests/fixtures/workflows/preconditions/current-cleanup-blocked.psd1 b/tests/fixtures/workflows/preconditions/current-cleanup-blocked.psd1 new file mode 100644 index 00000000..f88f86ba --- /dev/null +++ b/tests/fixtures/workflows/preconditions/current-cleanup-blocked.psd1 @@ -0,0 +1,28 @@ +@{ + Name = 'Current Alias Cleanup On Blocked' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + } + } + ) + Steps = @( + @{ + Name = 'CurrentBlockedStep' + Type = 'IdLE.Step.CurrentCleanupTest' + With = @{ Provider = 'Identity' } + # Precondition always fails: LifecycleEvent is Joiner, not Leaver. + Precondition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + OnPreconditionFalse = 'Blocked' + } + ) +} diff --git a/tests/fixtures/workflows/preconditions/current-cleanup-fail.psd1 b/tests/fixtures/workflows/preconditions/current-cleanup-fail.psd1 new file mode 100644 index 00000000..f601a45e --- /dev/null +++ b/tests/fixtures/workflows/preconditions/current-cleanup-fail.psd1 @@ -0,0 +1,28 @@ +@{ + Name = 'Current Alias Cleanup On Fail' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + } + } + ) + Steps = @( + @{ + Name = 'CurrentFailStep' + Type = 'IdLE.Step.CurrentCleanupTest' + With = @{ Provider = 'Identity' } + # Precondition always fails: LifecycleEvent is Joiner, not Leaver. + Precondition = @{ + Equals = @{ + Path = 'Plan.LifecycleEvent' + Value = 'Leaver' + } + } + OnPreconditionFalse = 'Fail' + } + ) +} From 5652c2a4b3959f17b512e7735ee6191871de3f82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:52:02 +0000 Subject: [PATCH 08/15] fix: wrap provider dispatch in try/catch so resolver failures are terminating with clear messages Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Invoke-IdleContextResolvers.ps1 | 26 ++++++++++++++----- .../New-IdlePlan.ContextResolvers.Tests.ps1 | 23 ++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index cd75d7d4..e928608b 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -155,13 +155,25 @@ function Invoke-IdleContextResolvers { } # --- Dispatch --- - $result = Invoke-IdleResolverCapabilityDispatch ` - -Capability $capability ` - -ProviderAlias $resolvedProviderAlias ` - -Providers $Providers ` - -With $with ` - -AuthSession $authSession ` - -ResolverPath $resolverPath + # Wrap in try/catch to ensure provider exceptions are always terminating and include + # resolver context in the error message, rather than silently continuing with a null + # result that later causes confusing template resolution failures. + $result = $null + try { + $result = Invoke-IdleResolverCapabilityDispatch ` + -Capability $capability ` + -ProviderAlias $resolvedProviderAlias ` + -Providers $Providers ` + -With $with ` + -AuthSession $authSession ` + -ResolverPath $resolverPath + } + catch { + throw [System.InvalidOperationException]::new( + "${resolverPath}: Provider '$resolvedProviderAlias' failed while resolving capability '$capability'. $($_.Exception.Message)", + $_.Exception + ) + } # --- Annotate entitlement results with source metadata --- if ($capability -eq 'IdLE.Entitlement.List') { diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 5c94dfc8..1c4113c0 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -919,6 +919,29 @@ Describe 'New-IdlePlan - ContextResolvers' { { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw -ExpectedMessage "*not a valid context path segment*" } + + It 'fails with a clear terminating error when provider throws during capability dispatch' { + $wfPath = Join-Path $script:FixturesPath 'resolver-empty-entitlements.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + # Provider whose ListEntitlements throws (simulates a real API failure) + $provider = [pscustomobject]@{} + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('IdLE.Entitlement.List') } + $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { + param([string]$IdentityKey) + throw [System.InvalidOperationException]::new('Simulated provider API failure') + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + # Should fail immediately with a clear message (not silently continue with null context) + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage "*ContextResolvers*failed while resolving capability*" + } } Context 'View stale-data regression (empty and null results)' { From 02d9dee3edeab8cd10e8a73f541df3f024e715b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:38:11 +0000 Subject: [PATCH 09/15] Fix GetAllPages pagination: guard @odata.nextLink property access under strict mode Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index cdebcf02..c5384a2e 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -138,7 +138,10 @@ function New-IdleEntraIDAdapter { $allItems += $response.value } - $nextLink = $response.'@odata.nextLink' + $nextLink = if ($null -ne $response -and $response.PSObject.Properties['@odata.nextLink']) { + $response.'@odata.nextLink' + } + else { $null } } return $allItems From 5b5154d08be793ac853bc6e42103ec9d2bba7a5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:49:30 +0000 Subject: [PATCH 10/15] fix: guard both .value and @odata.nextLink accesses in GetAllPages for full strict mode safety Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index c5384a2e..b6f67ebe 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -134,10 +134,12 @@ function New-IdleEntraIDAdapter { while ($null -ne $nextLink) { $response = $this.InvokeGraphRequest('GET', $nextLink, $AccessToken, $null) - if ($response.value) { + # Guard .value access: some endpoints do not wrap results in a value array + if ($null -ne $response -and $response.PSObject.Properties['value'] -and $null -ne $response.value) { $allItems += $response.value } + # Guard @odata.nextLink access: absent on last page and non-paginated endpoints $nextLink = if ($null -ne $response -and $response.PSObject.Properties['@odata.nextLink']) { $response.'@odata.nextLink' } From 78d3709388d6c0c714cab0b311572cd7349974d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:16:41 +0000 Subject: [PATCH 11/15] fix: make GetAllPages completely resilient - try/catch per property access, reset nextLink to null at loop top Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/New-IdleEntraIDAdapter.ps1 | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index b6f67ebe..b35bf0ee 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -134,16 +134,36 @@ function New-IdleEntraIDAdapter { while ($null -ne $nextLink) { $response = $this.InvokeGraphRequest('GET', $nextLink, $AccessToken, $null) - # Guard .value access: some endpoints do not wrap results in a value array - if ($null -ne $response -and $response.PSObject.Properties['value'] -and $null -ne $response.value) { - $allItems += $response.value - } + # Default next link to null before reading it from the response; + # any error reading the response fields terminates pagination safely + $nextLink = $null + + if ($null -ne $response) { + # Collect items: some endpoints do not wrap results in a value array + $items = try { + if ($response.PSObject.Properties.Name -contains 'value') { $response.value } else { $null } + } + catch { + Write-Verbose "GetAllPages: could not read 'value' from response: $_" + $null + } + if ($null -ne $items) { + $allItems += $items + } - # Guard @odata.nextLink access: absent on last page and non-paginated endpoints - $nextLink = if ($null -ne $response -and $response.PSObject.Properties['@odata.nextLink']) { - $response.'@odata.nextLink' + # Advance to next page if the response includes @odata.nextLink + $nextLink = try { + if ($response.PSObject.Properties.Name -contains '@odata.nextLink') { + $candidate = [string]$response.'@odata.nextLink' + if (-not [string]::IsNullOrWhiteSpace($candidate)) { $candidate } else { $null } + } + else { $null } + } + catch { + Write-Verbose "GetAllPages: could not read '@odata.nextLink' from response: $_" + $null + } } - else { $null } } return $allItems From 50e700e68f54f72b894700bdedf0fb4f28eae253 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:25:33 +0000 Subject: [PATCH 12/15] feat: Add Get-IdleEntraIDGraphResponseProperty helper + paging regression tests - Add private helper Get-IdleEntraIDGraphResponseProperty that safely reads response properties from PSCustomObject and IDictionary/hashtable without throwing under Set-StrictMode -Version Latest - Rewrite GetAllPages in New-IdleEntraIDAdapter to use the helper instead of inline try/catch blocks; reset $nextLink = $null at top of each iteration - Add provider-level regression tests: single-page no-nextLink, multi-page, hashtable responses, non-collection endpoints, direct helper unit tests Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Get-IdleEntraIDGraphResponseProperty.ps1 | 56 +++++++ .../Private/New-IdleEntraIDAdapter.ps1 | 26 +-- .../EntraIDIdentityProvider.Tests.ps1 | 156 ++++++++++++++++++ 3 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDGraphResponseProperty.ps1 diff --git a/src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDGraphResponseProperty.ps1 b/src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDGraphResponseProperty.ps1 new file mode 100644 index 00000000..6e6f83a4 --- /dev/null +++ b/src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDGraphResponseProperty.ps1 @@ -0,0 +1,56 @@ +function Get-IdleEntraIDGraphResponseProperty { + <# + .SYNOPSIS + Safely reads a named property from a Microsoft Graph API response object. + + .DESCRIPTION + Handles both PSCustomObject and IDictionary/hashtable response shapes from the + Microsoft Graph PowerShell module. Returns $null when the property is absent or + when any error occurs reading it, so callers never throw on missing response fields. + + .PARAMETER InputObject + The response object returned by the Graph API. May be $null, a PSCustomObject, + or a hashtable/IDictionary. + + .PARAMETER PropertyName + The name of the property to read (e.g. 'value', '@odata.nextLink'). + + .OUTPUTS + The property value, or $null when the property is absent, the input is $null, + or an error occurs. + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter()] + [object] $InputObject, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $PropertyName + ) + + if ($null -eq $InputObject) { + return $null + } + + try { + if ($InputObject -is [System.Collections.IDictionary]) { + if ($InputObject.Contains($PropertyName)) { + return $InputObject[$PropertyName] + } + return $null + } + + # PSCustomObject / general object — use PSObject.Properties to avoid strict-mode throw + $prop = $InputObject.PSObject.Properties[$PropertyName] + if ($null -ne $prop) { + return $prop.Value + } + return $null + } + catch { + Write-Verbose "Get-IdleEntraIDGraphResponseProperty: error reading '$PropertyName': $_" + return $null + } +} diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index b35bf0ee..1d12f23d 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -134,34 +134,20 @@ function New-IdleEntraIDAdapter { while ($null -ne $nextLink) { $response = $this.InvokeGraphRequest('GET', $nextLink, $AccessToken, $null) - # Default next link to null before reading it from the response; - # any error reading the response fields terminates pagination safely + # Reset before reading — ensures pagination always terminates $nextLink = $null if ($null -ne $response) { # Collect items: some endpoints do not wrap results in a value array - $items = try { - if ($response.PSObject.Properties.Name -contains 'value') { $response.value } else { $null } - } - catch { - Write-Verbose "GetAllPages: could not read 'value' from response: $_" - $null - } + $items = Get-IdleEntraIDGraphResponseProperty -InputObject $response -PropertyName 'value' if ($null -ne $items) { $allItems += $items } - # Advance to next page if the response includes @odata.nextLink - $nextLink = try { - if ($response.PSObject.Properties.Name -contains '@odata.nextLink') { - $candidate = [string]$response.'@odata.nextLink' - if (-not [string]::IsNullOrWhiteSpace($candidate)) { $candidate } else { $null } - } - else { $null } - } - catch { - Write-Verbose "GetAllPages: could not read '@odata.nextLink' from response: $_" - $null + # Advance to next page when @odata.nextLink is present and non-empty + $candidate = Get-IdleEntraIDGraphResponseProperty -InputObject $response -PropertyName '@odata.nextLink' + if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) { + $nextLink = [string]$candidate } } } diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index 912f4c51..31b51674 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -1298,3 +1298,159 @@ Describe 'EntraID identity provider - ResolveEntitlement' { } } } + +Describe 'EntraID adapter - GetAllPages paging regression' { + BeforeAll { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule + + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $privatePath = Join-Path -Path $repoRoot -ChildPath 'src' 'IdLE.Provider.EntraID' 'Private' + + # Source private helpers so they are accessible in this test scope. + # Get-IdleEntraIDGraphResponseProperty must be sourced before New-IdleEntraIDAdapter + # so the script-method closures created inside New-IdleEntraIDAdapter can resolve it. + . (Join-Path -Path $privatePath -ChildPath 'Get-IdleEntraIDGraphResponseProperty.ps1') + . (Join-Path -Path $privatePath -ChildPath 'New-IdleEntraIDAdapter.ps1') + } + + # Build an adapter whose InvokeGraphRequest returns a configurable sequence of pages. + function script:New-EntraIDPagingTestAdapter { + param([object[]] $PageSequence) + + $adapter = New-IdleEntraIDAdapter + + $script:EntraIDTestPages = $PageSequence + $script:EntraIDTestCallIndex = 0 + + $adapter | Add-Member -MemberType ScriptMethod -Name InvokeGraphRequest -Value { + param($Method, $Uri, $AccessToken, $Body) + $result = $script:EntraIDTestPages[$script:EntraIDTestCallIndex] + $script:EntraIDTestCallIndex++ + return $result + } -Force + + return $adapter + } + + Context 'Single-page response (PSCustomObject, no @odata.nextLink)' { + It 'Returns all items and does not throw (the paging bug scenario)' { + $page = [pscustomobject]@{ + value = @( + [pscustomobject]@{ id = 'g1'; displayName = 'Group 1' } + [pscustomobject]@{ id = 'g2'; displayName = 'Group 2' } + ) + # @odata.nextLink intentionally absent — last page scenario that previously threw + } + + $adapter = New-EntraIDPagingTestAdapter -PageSequence @($page) + # Direct call; any thrown exception will fail this test automatically + $result = $adapter.GetAllPages('https://graph.microsoft.com/v1.0/groups', 'fake-token') + $result | Should -HaveCount 2 + $result[0].id | Should -Be 'g1' + $result[1].id | Should -Be 'g2' + } + } + + Context 'Multi-page response (PSCustomObject, @odata.nextLink present on page 1)' { + It 'Collects items from all pages' { + $page1 = [pscustomobject]@{ + value = @([pscustomobject]@{ id = 'g1' }) + '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/groups?$skiptoken=abc' + } + $page2 = [pscustomobject]@{ + value = @([pscustomobject]@{ id = 'g2' }) + # no @odata.nextLink on last page + } + + $adapter = New-EntraIDPagingTestAdapter -PageSequence @($page1, $page2) + $result = $adapter.GetAllPages('https://graph.microsoft.com/v1.0/groups', 'fake-token') + $result | Should -HaveCount 2 + ($result | Select-Object -ExpandProperty id) | Should -Contain 'g1' + ($result | Select-Object -ExpandProperty id) | Should -Contain 'g2' + } + } + + Context 'Hashtable/IDictionary response' { + It 'Returns items when response is a hashtable without @odata.nextLink' { + $page = @{ + value = @( + @{ id = 'g1'; displayName = 'Group 1' } + ) + # @odata.nextLink absent + } + + $adapter = New-EntraIDPagingTestAdapter -PageSequence @($page) + $result = $null + { $result = $adapter.GetAllPages('https://graph.microsoft.com/v1.0/groups', 'fake-token') } | Should -Not -Throw + $result | Should -HaveCount 1 + } + + It 'Follows pagination when response is a hashtable with @odata.nextLink' { + $page1 = @{ + value = @(@{ id = 'g1' }) + '@odata.nextLink' = 'https://graph.microsoft.com/v1.0/groups?$skiptoken=ht' + } + $page2 = @{ + value = @(@{ id = 'g2' }) + } + + $adapter = New-EntraIDPagingTestAdapter -PageSequence @($page1, $page2) + $result = $adapter.GetAllPages('https://graph.microsoft.com/v1.0/groups', 'fake-token') + $result | Should -HaveCount 2 + } + } + + Context 'Response without a value wrapper (non-collection endpoint)' { + It 'Returns empty array when response has no value property' { + $page = [pscustomobject]@{ id = 'single-object'; displayName = 'Something' } + + $adapter = New-EntraIDPagingTestAdapter -PageSequence @($page) + $result = $adapter.GetAllPages('https://graph.microsoft.com/v1.0/something', 'fake-token') + $result | Should -HaveCount 0 + } + } + + Context 'Get-IdleEntraIDGraphResponseProperty helper' { + It 'Returns property value from a PSCustomObject' { + $obj = [pscustomobject]@{ foo = 'bar' } + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $obj -PropertyName 'foo' + $result | Should -Be 'bar' + } + + It 'Returns $null for a missing property on PSCustomObject' { + $obj = [pscustomobject]@{ foo = 'bar' } + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $obj -PropertyName 'missing' + $result | Should -BeNullOrEmpty + } + + It 'Returns property value from a hashtable' { + $ht = @{ foo = 'baz' } + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $ht -PropertyName 'foo' + $result | Should -Be 'baz' + } + + It 'Returns $null for a missing key in a hashtable' { + $ht = @{ foo = 'baz' } + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $ht -PropertyName 'missing' + $result | Should -BeNullOrEmpty + } + + It 'Returns $null when InputObject is $null' { + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $null -PropertyName 'foo' + $result | Should -BeNullOrEmpty + } + + It 'Reads @odata.nextLink from PSCustomObject (the paging bug scenario)' { + $obj = [pscustomobject]@{ '@odata.nextLink' = 'https://next.page' ; value = @() } + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $obj -PropertyName '@odata.nextLink' + $result | Should -Be 'https://next.page' + } + + It 'Returns $null for @odata.nextLink when property is absent (last page scenario)' { + $obj = [pscustomobject]@{ value = @() } + $result = Get-IdleEntraIDGraphResponseProperty -InputObject $obj -PropertyName '@odata.nextLink' + $result | Should -BeNullOrEmpty + } + } +} From 253c4805e7eb2c74742717146abcd08cbb68206e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:31:15 +0100 Subject: [PATCH 13/15] tests: fix indent issue in ficture --- .../workflows/resolver-two-auth-sessions.psd1 | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 b/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 index f73f8caa..3c04141b 100644 --- a/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 +++ b/tests/fixtures/workflows/resolver-two-auth-sessions.psd1 @@ -1,25 +1,25 @@ @{ - Name = 'Resolver Two Auth Sessions Test' - LifecycleEvent = 'Joiner' - ContextResolvers = @( - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ - IdentityKey = 'user1' - Provider = 'Identity' - AuthSessionName = 'Corp' - } - } - @{ - Capability = 'IdLE.Entitlement.List' - With = @{ - IdentityKey = 'user1' - Provider = 'Identity' - AuthSessionName = 'Tier0' - } - } - ) - Steps = @( - @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } - ) + Name = 'Resolver Two Auth Sessions Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Corp' + } + } + @{ + Capability = 'IdLE.Entitlement.List' + With = @{ + IdentityKey = 'user1' + Provider = 'Identity' + AuthSessionName = 'Tier0' + } + } + ) + Steps = @( + @{ Name = 'Step1'; Type = 'IdLE.Step.EmitEvent' } + ) } From 19b21519f0f1949b9009914f9a5718c75ade65f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:38:13 +0000 Subject: [PATCH 14/15] docs: update context-resolver.md - troubleshooting, profile Attributes path, View determinism note Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/workflows/context-resolver.md | 97 +++++++++++++++++++++----- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index 8aa80b9e..3df8ff38 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -77,6 +77,18 @@ For capabilities with defined view semantics, the engine builds deterministic Vi All profile and entitlement entries include `SourceProvider` and `SourceAuthSessionName` metadata for auditing. +:::info Profile Views are convenience views, not mirrors +Profile Views are **deterministic convenience aggregations**, not direct copies of a specific provider result. +When multiple `IdLE.Identity.Read` resolvers run (different providers or auth sessions), the aggregated Views reflect the last profile after a stable alphabetical sort (first by provider alias ascending, then by auth session key ascending). + +This means `Request.Context.Views.Identity.Profile` may differ from (or be a different object than) +`Request.Context.Providers...Identity.Profile` — that is by design. + +**When to use which path:** +- Use `Request.Context.Views.*` when you do not care which provider returned the profile (e.g., "does any profile exist"). +- Use `Request.Context.Providers...Identity.Profile` when you need the exact result from a specific provider and session. +::: + ### Step-relative Current alias (execution-time only) During **precondition** evaluation (execution time), you may use `Request.Context.Current.*` to refer @@ -139,7 +151,7 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Disabled identity {{Request.Context.Providers.Identity.Tier0.Identity.Profile.DisplayName}}' + Message = 'Disabled identity {{Request.Context.Providers.Identity.Tier0.Identity.Profile.Attributes.DisplayName}}' } } ) @@ -272,7 +284,33 @@ This enables auditing and per-source filtering when working with merged views. - Verify required `With` parameters. - Ensure template placeholders resolve correctly. - Remember: scoped path uses `Providers...`. - Views are only available for `IdLE.Entitlement.List`. + Views are only available for `IdLE.Entitlement.List` and `IdLE.Identity.Read`. + +### Profile path not found in Condition + +- Profile attributes are nested under the `Attributes` key, not promoted to top-level. + Use `...Identity.Profile.Attributes.DisplayName` not `...Identity.Profile.DisplayName`. +- Check the actual structure at plan time: `$plan.Request.Context.Providers...Identity.Profile | ConvertTo-Json -Depth 4` + +### View differs from source-of-truth path + +For `IdLE.Identity.Read`, profile Views are built by **last-writer-wins** with a deterministic sort order (provider alias ascending, then auth session key ascending). This means: + +- `Request.Context.Views.Identity.Profile` may contain a profile from a **different** provider/session than a specific scoped path. +- This is expected and intentional — Views are convenience aggregations, not direct copies. + +If the View contains an unexpected profile, check `SourceProvider` and `SourceAuthSessionName` on the profile object to identify its origin: + +```powershell +$plan.Request.Context.Views.Identity.Profile.SourceProvider +$plan.Request.Context.Views.Identity.Profile.SourceAuthSessionName +``` + +To get the profile from a specific provider, use the scoped source-of-truth path instead: + +```powershell +$plan.Request.Context.Providers.Entra.Default.Identity.Profile +``` ### Type conflict in context path @@ -286,45 +324,72 @@ This enables auditing and per-source filtering when working with merged views. ### Inspecting resolved context data -When working with complex objects (like entitlements), you may need to inspect the structure to determine the correct path syntax for Conditions or to understand what properties are available. +When working with complex resolver outputs (entitlements, profiles), inspect the plan object directly after calling `New-IdlePlan`. This is the recommended approach during authoring and debugging. **Do not rely on template substitution for this purpose** — template substitution only resolves scalar values and cannot serialize whole objects or lists. -**Method 1: Inspect the plan object after planning** +**Inspect the complete context tree:** ```powershell $plan = New-IdlePlan -WorkflowPath ./workflow.psd1 -Request $req -Providers $providers -# View the entire context structure -$plan.Request.Context | ConvertTo-Json -Depth 5 +# Full context structure (use Depth 8 for deeply nested Views) +$plan.Request.Context | ConvertTo-Json -Depth 8 + +# Scoped source-of-truth namespace only +$plan.Request.Context.Providers | ConvertTo-Json -Depth 8 + +# Engine-defined Views only +$plan.Request.Context.Views | ConvertTo-Json -Depth 8 +``` + +**Inspect a specific scoped path:** -# View scoped entitlements for a specific provider +```powershell +# Entitlements from one provider $plan.Request.Context.Providers.Identity.Default.Identity.Entitlements | ConvertTo-Json -Depth 2 -# View the global merged view +# Profile from one provider +$plan.Request.Context.Providers.Identity.Default.Identity.Profile | ConvertTo-Json -Depth 4 + +# Global merged View $plan.Request.Context.Views.Identity.Entitlements | ConvertTo-Json -Depth 2 ``` -**Method 2: Use Format-Table for quick inspection** +**Quick tabular view:** ```powershell -# After planning, inspect entitlements structure (global view) $plan.Request.Context.Views.Identity.Entitlements | Format-Table -AutoSize ``` -**Method 3: Access individual properties** +**Inspect individual properties to understand the path structure:** ```powershell -# Check if entitlements are objects with properties +# Check available properties on the profile object +$plan.Request.Context.Providers.Identity.Default.Identity.Profile | Get-Member + +# Access profile attributes — attributes are nested under the Attributes key +$plan.Request.Context.Providers.Identity.Default.Identity.Profile.Attributes + +# Check a specific attribute +$plan.Request.Context.Providers.Identity.Default.Identity.Profile.Attributes.DisplayName + +# Check an entitlement entry and its source metadata $plan.Request.Context.Views.Identity.Entitlements[0] | Get-Member $plan.Request.Context.Views.Identity.Entitlements[0].Id $plan.Request.Context.Views.Identity.Entitlements[0].SourceProvider ``` -**Using discovered structure in Conditions** - -Once you know the structure, use member-access enumeration in your condition paths: +**Translating discovered structure to Condition paths:** ```powershell -# Extract Id values from all entitlement objects (global view) +# Profile attribute — path must include Attributes +Condition = @{ + Like = @{ + Path = 'Request.Context.Providers.Identity.Default.Identity.Profile.Attributes.DisplayName' + Pattern = '* (Contractor)' + } +} + +# Entitlement IDs — member-access enumeration extracts all Id values from the list Condition = @{ NotContains = @{ Path = 'Request.Context.Views.Identity.Entitlements.Id' From a4f08ffecee6ccd650219f2a51e06d6c588aec24 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:14:00 +0100 Subject: [PATCH 15/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 1c4113c0..3966f3c8 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -538,6 +538,9 @@ Describe 'New-IdlePlan - ContextResolvers' { $ids = $globalView | ForEach-Object { $_.Id } $ids | Should -Contain 'entra-grp' $ids | Should -Contain 'ad-grp' + # Verify deterministic ordering: AD entitlements before Entra entitlements + $ids[0] | Should -Be 'ad-grp' + $ids[1] | Should -Be 'entra-grp' } It 'provider view contains only entitlements for that provider' {