diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 976c7f1e..7301c8cc 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -132,18 +132,59 @@ 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. + +Examples: +- `Request.Context.Providers.Entra.Default.Identity.Entitlements` +- `Request.Context.Providers.AD.CorpAdmin.Identity.Entitlements` +- `Request.Context.Providers.Identity.Default.Identity.Profile` -> **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). +### Views (engine-defined aggregations) + +For `IdLE.Entitlement.List`, the engine additionally builds (list merge — all entries preserved): + +| View | Path | +|---|---| +| 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), +> 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). + +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 @@ -155,29 +196,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..3df8ff38 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -28,10 +28,84 @@ 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` + +### Views (engine-defined aggregations) + +For capabilities with defined view semantics, the engine builds deterministic Views after each resolver: + +| View | Path | Description | +|---|---|---| +| 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. | + +**`IdLE.Entitlement.List`** — list merge (all entries preserved across all contributing providers/sessions): -A resolver entry is defined at workflow root level: +- `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. + +:::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 +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 +120,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 +140,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 +151,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.Attributes.DisplayName}}' } } ) @@ -90,61 +169,98 @@ 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: - -- Conditions -- Preconditions -- Templates +### Use the global View for "don't care about source" -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) ``` -Then guard on availability: +### 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 -Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } +@{ + 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' } +} ``` +### Guard destructive steps + +Only perform destructive actions if identity exists: + +```powershell +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 @@ -167,55 +283,120 @@ 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` 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 - 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 -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 -# View specific resolved data -$plan.Request.Context.Identity.Entitlements | ConvertTo-Json -Depth 2 +# Engine-defined Views only +$plan.Request.Context.Views | ConvertTo-Json -Depth 8 ``` -**Method 2: Use Format-Table for quick inspection** +**Inspect a specific scoped path:** ```powershell -# After planning, inspect entitlements structure -$plan.Request.Context.Identity.Entitlements | Format-Table -AutoSize +# Entitlements from one provider +$plan.Request.Context.Providers.Identity.Default.Identity.Entitlements | ConvertTo-Json -Depth 2 + +# 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 3: Access individual properties** +**Quick tabular view:** ```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 | Format-Table -AutoSize ``` -**Using discovered structure in Conditions** +**Inspect individual properties to understand the path structure:** -Once you know the structure (e.g., entitlements are objects with `Kind`, `Id`, `DisplayName`), use member-access enumeration in your condition paths: +```powershell +# 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 +``` + +**Translating discovered structure to Condition paths:** ```powershell -# Extract Id values from all entitlement objects +# 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.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..e928608b 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -7,13 +7,26 @@ 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) + 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 capability; zero matches or multiple matches both cause a fail-fast error. @@ -21,7 +34,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 +115,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.", @@ -134,22 +155,410 @@ function Invoke-IdleContextResolvers { } # --- Dispatch --- - $result = Invoke-IdleResolverCapabilityDispatch ` - -Capability $capability ` - -ProviderAlias $resolvedProviderAlias ` - -Providers $Providers ` - -With $with ` - -AuthSession $authSession ` - -ResolverPath $resolverPath - - # --- Write to predefined Request.Context path --- + # Wrap in try/catch to ensure provider exceptions are always terminating and include + # resolver context in the error message, rather than silently continuing with a null + # result that later causes confusing template resolution failures. + $result = $null + try { + $result = Invoke-IdleResolverCapabilityDispatch ` + -Capability $capability ` + -ProviderAlias $resolvedProviderAlias ` + -Providers $Providers ` + -With $with ` + -AuthSession $authSession ` + -ResolverPath $resolverPath + } + catch { + throw [System.InvalidOperationException]::new( + "${resolverPath}: Provider '$resolvedProviderAlias' failed while resolving capability '$capability'. $($_.Exception.Message)", + $_.Exception + ) + } + + # --- Annotate entitlement results with source metadata --- + if ($capability -eq 'IdLE.Entitlement.List') { + $result = @(Add-IdleEntitlementSourceMetadata -Entitlements @($result) -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey) + } + + # --- Annotate profile result with source metadata --- + if ($capability -eq 'IdLE.Identity.Read') { + $result = Add-IdleProfileSourceMetadata -Profile $result -SourceProvider $resolvedProviderAlias -SourceAuthSessionName $authSessionKey + } + + # --- Write to provider/auth-scoped path (source of truth) --- + # Path: Providers... $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 start with alphanumeric, followed by alphanumeric, hyphens, or underscores (max 64 chars total, no dots allowed).' -f $ResolverPath, $Label, $Value), + 'Workflow' + ) + } +} + +function Add-IdleEntitlementSourceMetadata { + <# + .SYNOPSIS + Annotates each entitlement entry with SourceProvider and SourceAuthSessionName metadata. + + .DESCRIPTION + Ensures every entitlement returned by IdLE.Entitlement.List resolvers carries source + information to support auditing, per-provider filtering, and merged view semantics. + + .PARAMETER Entitlements + Array of entitlement objects (hashtables or PSCustomObjects). + + .PARAMETER SourceProvider + The provider alias that produced these entitlements. + + .PARAMETER SourceAuthSessionName + The auth session key used ('Default' if no explicit session was specified). + + .OUTPUTS + Object[] + #> + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [object[]] $Entitlements, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceProvider, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceAuthSessionName + ) + + if ($null -eq $Entitlements -or $Entitlements.Count -eq 0) { + return @() + } + + $result = [System.Collections.Generic.List[object]]::new() + foreach ($item in $Entitlements) { + if ($null -eq $item) { + # Skip null entries; provider implementations must not return null items in an entitlement list. + continue + } + + if ($item -is [System.Collections.IDictionary]) { + $enriched = @{} + foreach ($key in $item.Keys) { $enriched[$key] = $item[$key] } + $enriched['SourceProvider'] = $SourceProvider + $enriched['SourceAuthSessionName'] = $SourceAuthSessionName + $result.Add($enriched) + } + else { + # PSCustomObject or other reference type — add properties non-destructively + $item | Add-Member -MemberType NoteProperty -Name 'SourceProvider' -Value $SourceProvider -Force + $item | Add-Member -MemberType NoteProperty -Name 'SourceAuthSessionName' -Value $SourceAuthSessionName -Force + $result.Add($item) + } + } + + return @($result) +} + +function Add-IdleProfileSourceMetadata { + <# + .SYNOPSIS + Annotates a profile object with SourceProvider and SourceAuthSessionName metadata. + + .DESCRIPTION + Ensures every profile returned by IdLE.Identity.Read resolvers carries source + information to support auditing and view semantics (including identifying which + provider/session a profile came from in aggregated views). + + .PARAMETER Profile + The profile object (hashtable or PSCustomObject) to annotate. + + .PARAMETER SourceProvider + The provider alias that produced the profile. + + .PARAMETER SourceAuthSessionName + The auth session key used ('Default' if no explicit session was specified). + + .OUTPUTS + Object + #> + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter()] + [AllowNull()] + [object] $Profile, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceProvider, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SourceAuthSessionName + ) + + if ($null -eq $Profile) { + return $null + } + + if ($Profile -is [System.Collections.IDictionary]) { + $enriched = @{} + foreach ($key in $Profile.Keys) { $enriched[$key] = $Profile[$key] } + $enriched['SourceProvider'] = $SourceProvider + $enriched['SourceAuthSessionName'] = $SourceAuthSessionName + return $enriched + } + else { + # PSCustomObject or other reference type — add properties non-destructively + $Profile | Add-Member -MemberType NoteProperty -Name 'SourceProvider' -Value $SourceProvider -Force + $Profile | Add-Member -MemberType NoteProperty -Name 'SourceAuthSessionName' -Value $SourceAuthSessionName -Force + return $Profile + } +} + +function Build-IdleContextResolverViews { + <# + .SYNOPSIS + Rebuilds engine-defined Views in Request.Context for capabilities with defined view semantics. + + .DESCRIPTION + Views are deterministic, engine-defined aggregations of scoped resolver outputs. + Called after each resolver execution to keep views current. + + IdLE.Entitlement.List view semantics (list merge, all entries are preserved): + - Global view (all providers, all sessions): + Request.Context.Views.Identity.Entitlements + - Provider view (one provider, all sessions): + Request.Context.Views.Providers..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 + + 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). + For IdLE.Identity.Read, views where multiple profiles could contribute use last-write-wins + with deterministic sort order (last provider or session alphabetically wins). + + .PARAMETER Context + The Request.Context hashtable to update. + + .PARAMETER Capability + The capability identifier (e.g., 'IdLE.Entitlement.List'). + + .PARAMETER CapabilitySubPath + The capability sub-path (e.g., 'Identity.Entitlements'). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Collections.IDictionary] $Context, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Capability, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $CapabilitySubPath + ) + + # Only capabilities with defined view semantics are processed. + if ($Capability -notin @('IdLE.Entitlement.List', 'IdLE.Identity.Read')) { + return + } + + $providersNode = if ($Context.Contains('Providers')) { $Context['Providers'] } else { $null } + if ($null -eq $providersNode -or -not ($providersNode -is [System.Collections.IDictionary])) { + return + } + + if ($Capability -eq 'IdLE.Entitlement.List') { + $globalList = [System.Collections.Generic.List[object]]::new() + $perProviderLists = @{} + $perSessionLists = @{} + $perProviderSessionLists = @{} + + # Stable ordering: sorted by ProviderAlias, then AuthSessionKey + $sortedProviders = @($providersNode.Keys | Sort-Object) + foreach ($providerAlias in $sortedProviders) { + $providerNode = $providersNode[$providerAlias] + if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } + + # Always initialize tracking for this provider so stale views are overwritten even when empty. + if (-not $perProviderLists.Contains($providerAlias)) { + $perProviderLists[$providerAlias] = [System.Collections.Generic.List[object]]::new() + } + if (-not $perProviderSessionLists.Contains($providerAlias)) { + $perProviderSessionLists[$providerAlias] = @{} + } + + $sortedAuthKeys = @($providerNode.Keys | Sort-Object) + foreach ($authKey in $sortedAuthKeys) { + $authNode = $providerNode[$authKey] + if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } + + # Always initialize tracking for this session so stale views are overwritten even when empty. + if (-not $perSessionLists.Contains($authKey)) { + $perSessionLists[$authKey] = [System.Collections.Generic.List[object]]::new() + } + if (-not $perProviderSessionLists[$providerAlias].Contains($authKey)) { + $perProviderSessionLists[$providerAlias][$authKey] = [System.Collections.Generic.List[object]]::new() + } + + # Navigate the CapabilitySubPath within the auth node; null means not yet populated. + $items = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath + if ($null -eq $items) { continue } + + foreach ($item in @($items)) { + $globalList.Add($item) + $perProviderLists[$providerAlias].Add($item) + $perSessionLists[$authKey].Add($item) + $perProviderSessionLists[$providerAlias][$authKey].Add($item) + } + } + } + + # Always write all views (including empty arrays) to ensure stale data from prior runs is cleared. + Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value @($globalList) + + foreach ($providerAlias in ($perProviderLists.Keys | Sort-Object)) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$providerAlias.$CapabilitySubPath" -Value @($perProviderLists[$providerAlias]) + } + + foreach ($authKey in ($perSessionLists.Keys | Sort-Object)) { + Set-IdleContextValue -Context $Context -Path "Views.Sessions.$authKey.$CapabilitySubPath" -Value @($perSessionLists[$authKey]) + } + + foreach ($pAlias in ($perProviderSessionLists.Keys | Sort-Object)) { + foreach ($aKey in ($perProviderSessionLists[$pAlias].Keys | Sort-Object)) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value @($perProviderSessionLists[$pAlias][$aKey]) + } + } + return + } + + if ($Capability -eq 'IdLE.Identity.Read') { + # Profile views use last-non-null-wins with deterministic (sorted) ordering. + # When multiple profiles exist for a view scope, the last non-null profile in sort order wins. + # All views (including null) are always written to clear stale data from prior runs. + $globalProfile = $null + $perProviderProfiles = @{} + $perSessionProfiles = @{} + $perProviderSessionProfiles = @{} + + # Stable ordering: sorted by ProviderAlias, then AuthSessionKey + $sortedProviders = @($providersNode.Keys | Sort-Object) + foreach ($providerAlias in $sortedProviders) { + $providerNode = $providersNode[$providerAlias] + if ($null -eq $providerNode -or -not ($providerNode -is [System.Collections.IDictionary])) { continue } + + # Always initialize tracking for this provider so stale views are overwritten even when null. + if (-not $perProviderProfiles.Contains($providerAlias)) { + $perProviderProfiles[$providerAlias] = $null + } + if (-not $perProviderSessionProfiles.Contains($providerAlias)) { + $perProviderSessionProfiles[$providerAlias] = @{} + } + + $sortedAuthKeys = @($providerNode.Keys | Sort-Object) + foreach ($authKey in $sortedAuthKeys) { + $authNode = $providerNode[$authKey] + if ($null -eq $authNode -or -not ($authNode -is [System.Collections.IDictionary])) { continue } + + # Always initialize tracking for this session so stale views are overwritten even when null. + if (-not $perSessionProfiles.Contains($authKey)) { + $perSessionProfiles[$authKey] = $null + } + + $profile = Get-IdleValueByPath -Object $authNode -Path $CapabilitySubPath + + # Aggregated views use last-non-null-wins semantics. + if ($null -ne $profile) { $globalProfile = $profile } + if ($null -ne $profile) { $perProviderProfiles[$providerAlias] = $profile } + if ($null -ne $profile) { $perSessionProfiles[$authKey] = $profile } + + # Per-provider+session view: always write exact value (even null). + $perProviderSessionProfiles[$providerAlias][$authKey] = $profile + } + } + + # Always write all views (including null) to ensure stale data from prior runs is cleared. + Set-IdleContextValue -Context $Context -Path "Views.$CapabilitySubPath" -Value $globalProfile + + foreach ($pAlias in ($perProviderProfiles.Keys | Sort-Object)) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.$CapabilitySubPath" -Value $perProviderProfiles[$pAlias] + } + + foreach ($aKey in ($perSessionProfiles.Keys | Sort-Object)) { + Set-IdleContextValue -Context $Context -Path "Views.Sessions.$aKey.$CapabilitySubPath" -Value $perSessionProfiles[$aKey] + } + + foreach ($pAlias in ($perProviderSessionProfiles.Keys | Sort-Object)) { + foreach ($aKey in ($perProviderSessionProfiles[$pAlias].Keys | Sort-Object)) { + Set-IdleContextValue -Context $Context -Path "Views.Providers.$pAlias.Sessions.$aKey.$CapabilitySubPath" -Value $perProviderSessionProfiles[$pAlias][$aKey] + } + } + } +} + function Get-IdleAuthSessionBroker { <# .SYNOPSIS diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 2374cca9..0d137808 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -324,6 +324,51 @@ 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]) { + # 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'] + } + } + 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 +443,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 { @@ -421,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 } } @@ -428,6 +482,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/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 cdebcf02..1d12f23d 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -134,11 +134,22 @@ function New-IdleEntraIDAdapter { while ($null -ne $nextLink) { $response = $this.InvokeGraphRequest('GET', $nextLink, $AccessToken, $null) - if ($response.value) { - $allItems += $response.value - } + # 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 = Get-IdleEntraIDGraphResponseProperty -InputObject $response -PropertyName 'value' + if ($null -ne $items) { + $allItems += $items + } - $nextLink = $response.'@odata.nextLink' + # 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 + } + } } return $allItems 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 0552b1fa..3966f3c8 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,668 @@ 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' + # 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' { + $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' + } + + 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 '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' + + $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*" + } + + 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)' { + 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' + + $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/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 + } + } +} 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' + } + ) +} 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-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' } + ) +} 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..3c04141b --- /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' } + ) +}