Skip to content

ContextResolvers: Provider/Auth-scoped namespace, deterministic Views, Current alias#259

Merged
blindzero merged 15 commits intomainfrom
copilot/fix-context-resolvers-namespace
Mar 13, 2026
Merged

ContextResolvers: Provider/Auth-scoped namespace, deterministic Views, Current alias#259
blindzero merged 15 commits intomainfrom
copilot/fix-context-resolvers-namespace

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 4, 2026

  • Fix GetAllPages in New-IdleEntraIDAdapter.ps1: guard @odata.nextLink access so it doesn't throw under Set-StrictMode -Version Latest when the last page has no nextLink property
  • Introduce private helper Get-IdleEntraIDGraphResponseProperty in the Entra provider that safely reads named properties from both PSCustomObject and IDictionary/hashtable Graph API responses
  • Rewrite GetAllPages to use the helper for both .value and @odata.nextLink access
  • Add provider-level paging regression tests
  • Update docs/use/workflows/context-resolver.md:
    • Troubleshooting: use -Depth 8, add targeted Providers/Views sub-tree inspection commands, add note that template substitution is scalar-only and cannot log whole objects/lists
    • Fix profile path example: Profile.DisplayNameProfile.Attributes.DisplayName (canonical attribute path)
    • Add :::info admonition explaining Profile Views are deterministic convenience aggregations (last-writer-wins, sorted alphabetically), not mirrors of a specific provider result, with guidance on when to use Views vs scoped source-of-truth paths
    • Add new troubleshooting entries: "Profile path not found in Condition" and "View differs from source-of-truth path"
Original prompt

This section details on the original issue you should resolve

<issue_title>ContextResolvers: Provider/Auth-scoped Context Namespaces + Deterministic Views without wildcards</issue_title>
<issue_description>## Problem Statement

ContextResolvers write capability results into a single, capability-defined Request.Context target path. This makes it impossible (or unsafe) to use the same capability multiple times for different Identity Providers and/or different AuthSession contexts (e.g., Entra + AD, or multiple Entra sessions), because results collide/overwrite each other.

As a result:

  • Workflows cannot safely combine multiple identity systems for the same capability (e.g., IdLE.Entitlement.List from Entra and from AD).
  • Conditions, Preconditions, and Template Substitution cannot reliably reference “the right” context source when multiple providers/sessions are used.

Proposed Solution

This issue implements a breaking but consistent design with the following guardrails:

  1. No wildcard paths in Get-IdleValuePath / condition path resolution (e.g. no Providers.*.*).
  2. A provider/auth-scoped namespace becomes the source of truth for all ContextResolver outputs.
  3. “Wildcard-equivalent” use cases are supported via deterministic, engine-defined Views, not by wildcard path semantics.
  4. A step-relative view is supported for execution-time evaluation (Current), derived from the step/resolver Provider/Auth settings.
  5. No additional action is required for Entitlements pattern matching beyond the ongoing PR/Issue introducing pattern-based condition operators (see Additional Context).

1) Provider/Auth-scoped Context Namespace (Source of Truth)

Introduce (or standardize) a provider/auth-scoped structure under Request.Context:

  • Source of truth path format:
    • Request.Context.Providers.<ProviderAlias>.<AuthSessionKey>.<CapabilityRoot>.<...>

Where:

  • <ProviderAlias> is the provider name used in IdLE (e.g., Entra, AD) and must be a valid path segment.
  • <AuthSessionKey> is:
    • Default when no With.AuthSessionName is specified
    • otherwise the exact With.AuthSessionName

Examples:

  • Request.Context.Providers.Entra.Default.Identity.Profile
  • Request.Context.Providers.Entra.CorpAdmin.Identity.Entitlements
  • Request.Context.Providers.AD.Default.Identity.Entitlements

Implementation rule: ContextResolvers MUST write their results into the scoped namespace above (in addition to any engine-defined Views, see below).

2) Deterministic Views (Wildcard-equivalent Without Wildcards)

Add engine-defined “Views” for common “wildcard” use cases.

  • Global view (all providers, all auth sessions):

    • Request.Context.Views.<CapabilityRoot>.<...>
  • Provider view (one provider, all auth sessions):

    • Request.Context.Views.Providers.<ProviderAlias>.<CapabilityRoot>.<...>

Examples (Entitlements):

  • Request.Context.Views.Identity.Entitlements (all providers, all auth)
  • Request.Context.Views.Providers.Entra.Identity.Entitlements (Entra only, all auth)

View semantics are capability-specific and must be explicitly defined and tested.
No view implies no merge.

Entitlements View Semantics (required)

For IdLE.Entitlement.List results:

  • Source of truth:
    • Request.Context.Providers.<ProviderAlias>.<AuthSessionKey>.Identity.Entitlements
  • Views:
    • Request.Context.Views.Identity.Entitlements = merge/flatten of all scoped entitlement lists
    • Request.Context.Views.Providers.<ProviderAlias>.Identity.Entitlements = merge/flatten of all scoped entitlement lists for that provider

Additionally, each entitlement entry MUST include source metadata:

  • SourceProvider (string)
  • SourceAuthSessionName (string, Default if not specified)

This enables:

  • “don’t care where membership comes from” (use global view)
  • “provider-specific checks” (use provider view or source-of-truth path)
  • auditing/debugging (source metadata always present in merged lists)

3) Step-relative Context Alias (Execution-time)

Introduce a step-relative alias path:

  • Request.Context.Current.<CapabilityRoot>.<...>

Resolution rule:

  • Current is resolved using the Provider/Auth context of the executing unit:
    • For step execution / preconditions: from Step.With.Provider + Step.With.AuthSessionName (or Default)
    • For ContextResolvers: from the resolver’s Provider/Auth settings

Scope restriction:

  • Request.Context.Current.* MUST NOT be used in plan-time conditions (where there is no single execution context).
  • It MAY be used in preconditions and other execution-time evaluations.

4) Reference Behavior (Conditions, Preconditions, Template Substitution)

  • Provider/Auth may be referenced:
    • explicitly via source-of-truth paths (Request.Context.Providers...)
    • implicitly via deterministic Views (Request.Context.Views...)
    • relatively via Request.Context.Current... (execution-time only)

**No wildcard...


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI and others added 2 commits March 4, 2026 12:58
…nt alias, and source metadata for ContextResolvers

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
…er null entitlement entries

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix ContextResolvers for provider/auth-scoped namespaces ContextResolvers: Provider/Auth-scoped namespace, deterministic Views, Current alias Mar 4, 2026
Copy link
Copy Markdown
Owner

@blindzero blindzero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot from views I miss some parts. we need a "condensed" view for

  • per provider, per AuthSession (default)
  • per provider, all AuthSessions combined
  • all provider combined, but per AuthSession
  • all provider, all AuthSessions (which is realized)

…lverViews

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 4, 2026

@copilot from views I miss some parts. we need a "condensed" view for

  • per provider, per AuthSession (default)...

Added the two missing view types in commit 21d98b4. All four views are now built for IdLE.Entitlement.List:

View Path
All providers, all sessions Request.Context.Views.Identity.Entitlements
One provider, all sessions Request.Context.Views.Providers.<ProviderAlias>.Identity.Entitlements
All providers, one session Request.Context.Views.Sessions.<AuthSessionKey>.Identity.Entitlements
One provider, one session Request.Context.Views.Providers.<ProviderAlias>.Sessions.<AuthSessionKey>.Identity.Entitlements

The nested Sessions key under provider views keeps the path structure unambiguous (no delimiter collision). Tests and docs updated.

Copy link
Copy Markdown
Owner

@blindzero blindzero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot the original issue was not limited to entitlements but also incl. profiles. this is missing still,

…our scopes)

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 4, 2026

@copilot the original issue was not limited to entitlements but also incl. profiles. this is missing still,

Fixed in commit c4766ca. IdLE.Identity.Read now has all four views (same scope pattern as entitlements):

View Path
All providers, all sessions Request.Context.Views.Identity.Profile
One provider, all sessions Request.Context.Views.Providers.<ProviderAlias>.Identity.Profile
All providers, one session Request.Context.Views.Sessions.<AuthSessionKey>.Identity.Profile
One provider, one session Request.Context.Views.Providers.<ProviderAlias>.Sessions.<AuthSessionKey>.Identity.Profile

Profile views use last-write-wins with deterministic sort order (provider alias asc, then auth key asc). Profile objects are also now annotated with SourceProvider and SourceAuthSessionName, consistent with entitlements.

@blindzero blindzero marked this pull request as ready for review March 4, 2026 14:10
Copilot AI review requested due to automatic review settings March 4, 2026 14:10
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4766ca356

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates IdLE’s ContextResolvers to avoid cross-provider/auth-session collisions by writing resolver outputs to provider/auth-scoped namespaces, then exposing deterministic, engine-built “Views” for common aggregation patterns. It also introduces an execution-time-only Request.Context.Current alias for step precondition evaluation, plus validation of provider/auth path segments.

Changes:

  • Write ContextResolver outputs to Request.Context.Providers.<ProviderAlias>.<AuthSessionKey>.<CapabilitySubPath> and rebuild deterministic Request.Context.Views.* after each resolver.
  • Add SourceProvider / SourceAuthSessionName metadata to entitlement entries and identity profiles.
  • Add fixture workflows + expanded Pester coverage; update example workflow and docs to reflect new paths and Views.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 Implements provider/auth-scoped outputs, source metadata, path validation, and deterministic Views rebuilding.
src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 Adds execution-time Request.Context.Current alias during precondition evaluation.
tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 Updates existing resolver tests and adds new coverage for multi-provider/session scoping, Views, validation, and Current.
tests/fixtures/workflows/*.psd1 Adds/updates workflow fixtures to exercise multi-provider, multi-session, invalid segments, and Current precondition usage.
examples/workflows/mock/joiner-with-context-resolvers.psd1 Updates example to reference Views/scoped paths instead of legacy flat paths.
docs/use/workflows/context-resolver.md Documents scoped namespaces, Views, and Current alias; updates examples and troubleshooting guidance.
docs/reference/capabilities.md Updates capability reference docs to describe scoped outputs and Views for both capabilities.
Comments suppressed due to low confidence (3)

docs/use/workflows/context-resolver.md:276

  • This troubleshooting note says “Views are only available for IdLE.Entitlement.List”, but the same document (and docs/reference/capabilities.md) describes deterministic Views for IdLE.Identity.Read as well. Please update/remove this line to avoid contradicting the rest of the docs.
- Ensure template placeholders resolve correctly.
- Remember: scoped path uses `Providers.<Alias>.<AuthKey>.<SubPath>`.
  Views are only available for `IdLE.Entitlement.List`.

tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1:541

  • This test name/docstring claims the global view is “sorted by provider alias then auth session key”, but the assertions only check that both IDs are present and do not validate order. Either assert the expected ordering explicitly or adjust the test description so it matches what’s being verified.
        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'
        }

src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1:469

  • The Request.Context.Current alias is not guaranteed to be cleaned up on all control-flow paths. In particular, when a precondition fails with OnPreconditionFalse = Fail or Blocked, the code breaks out of the loop without removing Current, and exceptions thrown during Assert-IdleConditionPathsResolvable / Test-IdleCondition would also bypass cleanup. Use a try/finally (or explicit cleanup before break) to ensure Current is always removed/restored.
        if ($null -ne $stepPrecondition) {
            $preconditionPassed = $true
            if ($stepPrecondition -isnot [System.Collections.IDictionary]) {
                # Fail closed: a malformed or unexpected node type is treated as a failed precondition.
                $preconditionPassed = $false
            }
            else {
                # Validate that all non-Exists paths exist at execution time.
                # Exists operator paths are excluded because Exists semantics intentionally allow missing paths.
                Assert-IdleConditionPathsResolvable -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext -StepName $stepName -Source 'Precondition' -ExcludeExistsOperatorPaths
                if (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) {
                    $preconditionPassed = $false
                }
            }

            if (-not $preconditionPassed) {
                $onPreconditionFalse = [string](Get-IdlePropertyValue -Object $step -Name 'OnPreconditionFalse')
                if ([string]::IsNullOrWhiteSpace($onPreconditionFalse)) { $onPreconditionFalse = 'Blocked' }

                # Always emit StepPreconditionFailed for engine observability.
                $context.EventSink.WriteEvent(
                    'StepPreconditionFailed',
                    "Step '$stepName' precondition check failed.",
                    $stepName,
                    @{
                        StepType            = $stepType
                        Index               = $i
                        OnPreconditionFalse = $onPreconditionFalse
                    }
                )

                # Emit the caller-configured PreconditionEvent if present.
                $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent'
                if ($null -ne $pcEvt) {
                    $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type')
                    $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message')
                    $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data'
                    # PreconditionEvent.Data is validated as a hashtable at planning time and
                    # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here.
                    $pcEvtDataHt = if ($pcEvtData -is [System.Collections.IDictionary]) { [hashtable]$pcEvtData } else { $null }
                    $context.EventSink.WriteEvent($pcEvtType, $pcEvtMsg, $stepName, $pcEvtDataHt)
                }

                if ($onPreconditionFalse -eq 'Fail') {
                    $failed = $true
                    $stepResults += [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $stepName
                        Type       = $stepType
                        Status     = 'Failed'
                        Error      = 'Precondition check failed.'
                        Attempts   = 0
                    }
                    $context.EventSink.WriteEvent(
                        'StepFailed',
                        "Step '$stepName' failed (precondition check failed).",
                        $stepName,
                        @{
                            StepType = $stepType
                            Index    = $i
                            Error    = 'Precondition check failed.'
                        }
                    )
                }
                elseif ($onPreconditionFalse -eq 'Continue') {
                    # Emit events and skip the step; continue to subsequent steps.
                    $stepResults += [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $stepName
                        Type       = $stepType
                        Status     = 'PreconditionSkipped'
                        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 {
                    # Default: Blocked. Does not trigger OnFailureSteps.
                    $blocked = $true
                    $stepResults += [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $stepName
                        Type       = $stepType
                        Status     = 'Blocked'
                        Attempts   = 0
                    }
                    $context.EventSink.WriteEvent(
                        'StepBlocked',
                        "Step '$stepName' blocked (precondition check failed).",
                        $stepName,
                        @{
                            StepType = $stepType
                            Index    = $i
                        }
                    )
                }

                break
            }

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 4, 2026

Code Coverage Report

Overall Project 74.79% 🍏

There is no coverage information present for the Files changed

… cleanup on Fail/Blocked

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copy link
Copy Markdown
Owner

@blindzero blindzero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i get this error:

PS C:\Users\adm-mafl-server\Downloads\idle> $plan = New-IdlePlan -WorkflowPath .\context-resolver.psd1 -Request $request -Providers $providers 
MethodInvocationException: C:\Users\adm-mafl-server\Downloads\idle\IdentityLifecycleEngine\src\IdLE.Core\Private\Invoke-IdleContextResolvers.ps1:714:24 
Line |
 714 |  …      return @($provider.ListEntitlements($identityKey, $AuthSession)) …
     |                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Exception calling "ListEntitlements" with "2" argument(s): "Exception calling "ListUserGroups" with "2" argument(s): "Exception calling "GetAllPages" with "2" 
     | argument(s): "The property '@odata.nextLink' cannot be found on this object. Verify that the property exists."""
MethodInvocationException: C:\Users\adm-mafl-server\Downloads\idle\IdentityLifecycleEngine\src\IdLE.Core\Private\Invoke-IdleContextResolvers.ps1:716:20 
Line | 
 716 |              return @($provider.ListEntitlements($identityKey))
     |                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Exception calling "ListEntitlements" with "1" argument(s): "Exception calling "ResolveIdentity" with "2" argument(s): "Exception calling "GetUserByUpn" with "2" 
     | argument(s): "Exception calling "InvokeGraphRequest" with "4" argument(s): "Unable to index into an object of type "System.Net.Http.Headers.HttpResponseHeaders".""""  
OperationStopped: C:\Users\adm-mafl-server\Downloads\idle\IdentityLifecycleEngine\src\IdLE.Core\Private\Resolve-IdleTemplateString.ps1:115:13 
Line | 
 115 |              throw [System.ArgumentException]::new(
     |              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Template resolution error in step 'Profile SoT - Entra.Default': Path 'Request.Context.Providers.Entra.Default.Identity.Profile' resolved to null or does not exist.   
     | Ensure the request contains all required values. (Parameter 'Workflow')

for a request only using emit event and context resolver for testing the different scnarios.

I used this session and provider setting:

$authSessions = New-IdleAuthSession -SessionMap @{
    @{ AuthSessionName = 'AD' }  = @{ AuthSessionType = 'Credential'; Credential = $adCred }
    @{ AuthSessionName = 'Entra' } = @{ AuthSessionType = 'OAuth'; Credential = $entraAccessToken }
    @{ AuthSessionName = 'EXO' } = @{ AuthSessionType = 'OAuth'; Credential = $exoAccessToken }
}

# Creating Providers
$providers = @{
    AD = New-IdleADIdentityProvider
    Entra = New-IdleEntraIDIdentityProvider
    EXO = New-IdleExchangeOnlineProvider
    AuthSessionBroker = $authSessions
}

Default doesnt mean "the one if only one", but "if not named". Hence it seems, that Entra.Default and AD.Default do not exist.
In such a case the error should be not a 3 point stacktrace but rather a clear error message (the last of the three messages).

When removing the .Default ones I got the next error:

Line | 
 714 |  …      return @($provider.ListEntitlements($identityKey, $AuthSession)) …
     |                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Exception calling "ListEntitlements" with "2" argument(s): "Exception calling "ListUserGroups" with "2" argument(s): "Exception calling "GetAllPages" with "2"
     | argument(s): "The property '@odata.nextLink' cannot be found on this object. Verify that the property exists."""
MethodInvocationException: C:\Users\adm-mafl-server\Downloads\idle\IdentityLifecycleEngine\src\IdLE.Core\Private\Invoke-IdleContextResolvers.ps1:716:20 
Line | 
 716 |              return @($provider.ListEntitlements($identityKey))
     |                     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Exception calling "ListEntitlements" with "1" argument(s): "Exception calling "ResolveIdentity" with "2" argument(s): "Exception calling "GetUserByUpn" with "2" 
     | argument(s): "Exception calling "InvokeGraphRequest" with "4" argument(s): "Unable to index into an object of type "System.Net.Http.Headers.HttpResponseHeaders".""""  
OperationStopped: C:\Users\adm-mafl-server\Downloads\idle\IdentityLifecycleEngine\src\IdLE.Core\Private\Resolve-IdleTemplateString.ps1:115:13 
Line | 
 115 |              throw [System.ArgumentException]::new(
     |              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
     | Template resolution error in step 'Entitlement SoT - Entra.Entra': Path 'Request.Context.Providers.Entra.Entra.Identity.Entitlements' resolved to null or does not     
     | exist. Ensure the request contains all required values. (Parameter 'Workflow')

Here I am wondering why Request.Context.Providers.Entra.Entra.Identity.Entitlements does not exist now, because it is a Source of Truth.

…ccess, reset nextLink to null at loop top

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 8, 2026

@copilot i still get the error and the plan fails

OperationStopped: src\IdLE.Core\Private\Invoke-IdleContextResolvers.ps1:172:13 ...

Fixed in 78d3709. GetAllPages is now fully resilient:

  1. $nextLink is reset to $null at the top of each loop iteration — pagination always terminates even if the response object is unexpected.
  2. Both .value and @odata.nextLink are read inside individual try/catch blocks — any error from the Microsoft module when reading those fields is caught, logged with Write-Verbose, and treated as "no more items / no next page" rather than propagating and failing the plan.

Please make sure you pull the latest commit on this branch before testing again.

@blindzero
Copy link
Copy Markdown
Owner

The remaining error is not a ContextResolver design problem anymore, but a provider robustness issue exposed by the new issue #253 behavior.

PR #259 correctly makes ContextResolvers resolve provider/auth-scoped entitlement data during plan build. That means Entra entitlement listing is now exercised deterministically and the latent paging bug becomes visible.

Observed failure:
ContextResolvers[n]: Provider 'Entra' failed while resolving capability 'IdLE.Entitlement.List' ...
"The property '@odata.nextLink' cannot be found on this object."

Assessment:

  • The throw in Invoke-IdleContextResolvers is correct and should stay.
  • The actual fix belongs in the Entra adapter paging code.
  • Missing @odata.nextLink on the final Graph page must be treated as a normal termination condition, not as an exception.
  • We need regression tests for the real response shapes, otherwise this class of bug will reappear.

Requested change:

Copy link
Copy Markdown
Owner

@blindzero blindzero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Please fix this as a provider-layer regression in the Entra adapter, not in ContextResolvers.

Problem:
PR #259 correctly makes ContextResolvers resolve provider/auth-scoped entitlement data during plan build for issue #253. This now reliably executes the Entra entitlement path:
ListEntitlements -> Adapter.ListUserGroups -> Adapter.GetAllPages

The current failure
"The property '@odata.nextLink' cannot be found on this object"
shows that the Entra paging code is still not fully resilient under Set-StrictMode -Version Latest when the final Graph page does not contain @odata.nextLink.

Required fix:

  1. Keep Invoke-IdleContextResolvers fail-fast behavior unchanged.

    • Do NOT swallow provider errors in the core.
    • The throw at Invoke-IdleContextResolvers is correct and should remain.
  2. Fix the Entra adapter paging logic in New-IdleEntraIDAdapter.ps1.

    • Introduce a small internal helper for safe response property access, e.g.:
      Get-IdleEntraGraphResponseProperty -InputObject -Name
    • It must:
      • return $null when the property/key is absent
      • support both IDictionary/hashtable and PSObject-based responses
      • never throw for missing properties under StrictMode
    • Use this helper for BOTH:
      • 'value'
      • '@odata.nextLink'
  3. Update GetAllPages to be explicitly termination-safe.

    • Set $nextLink = $null at the start of every loop iteration after receiving the response
    • Read the next link only via the safe helper
    • Missing '@odata.nextLink' must mean "last page", not failure
    • Preserve current behavior for normal multi-page responses
  4. Add regression tests that reproduce the real bug.
    At minimum add provider tests covering:

    • single-page response with .value and without '@odata.nextLink'
    • multi-page response where first page has '@odata.nextLink' and final page does not
    • response represented as PSCustomObject
    • response represented as hashtable / IDictionary
    • StrictMode-safe behavior (missing property must not throw)
  5. Add one integration-style regression test on the plan/context-resolver path.

    • Use New-IdlePlan with a workflow that triggers IdLE.Entitlement.List via ContextResolvers
    • Back the Entra provider with a fake adapter that returns a final page without '@odata.nextLink'
    • Assert that plan build succeeds and Request.Context.Providers / Views are populated as expected
    • This verifies the issue #253 scenario end-to-end and prevents future regressions
  6. Keep the PR focused.

    • No unrelated refactors
    • No fallback behavior changes in the core
    • Only provider resilience + regression coverage

…sion tests

- Add private helper Get-IdleEntraIDGraphResponseProperty that safely reads
  response properties from PSCustomObject and IDictionary/hashtable without
  throwing under Set-StrictMode -Version Latest
- Rewrite GetAllPages in New-IdleEntraIDAdapter to use the helper instead of
  inline try/catch blocks; reset $nextLink = $null at top of each iteration
- Add provider-level regression tests: single-page no-nextLink, multi-page,
  hashtable responses, non-collection endpoints, direct helper unit tests

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 12, 2026

@copilot Please fix this as a provider-layer regression in the Entra adapter, not in ContextResolvers.

Problem:...

Fixed in 50e700e.

Changes:

  • Added private helper Get-IdleEntraIDGraphResponseProperty (src/IdLE.Provider.EntraID/Private/Get-IdleEntraIDGraphResponseProperty.ps1) that safely reads a named property from both PSCustomObject and IDictionary/hashtable responses, returning $null for absent properties instead of throwing
  • Rewrote GetAllPages in New-IdleEntraIDAdapter.ps1 to use the helper — the inline try/catch blocks are gone; $nextLink is still reset to $null at the top of each iteration to guarantee termination
  • Invoke-IdleContextResolvers fail-fast behavior is untouched
  • Added regression tests in EntraIDIdentityProvider.Tests.ps1: single-page without @odata.nextLink (the original bug), multi-page, hashtable responses, non-collection endpoints, and direct Get-IdleEntraIDGraphResponseProperty unit tests

Copilot AI requested a review from blindzero March 12, 2026 20:26
Copy link
Copy Markdown
Owner

@blindzero blindzero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Request changes

The implementation looks close, but the current docs/examples still leave important authoring and troubleshooting gaps around the new scoped ContextResolver outputs from #253.

Please update the documentation and examples before merge.

Required documentation changes

  1. Update the ContextResolver workflow docs to document the recommended debugging workflow explicitly.

    • Add a troubleshooting section that shows host-side inspection with ConvertTo-Json, for example:
      • $plan.Request.Context | ConvertTo-Json -Depth 8
      • $plan.Request.Context.Providers | ConvertTo-Json -Depth 8
      • $plan.Request.Context.Views | ConvertTo-Json -Depth 8
    • Make it clear that this is the current recommended way to inspect complex resolver outputs during authoring/debugging.
    • Also state explicitly that normal template substitution remains scalar-only and is not the right mechanism for logging whole objects/lists.
  2. Clarify the actual profile shape returned by IdLE.Identity.Read.

    • The docs/examples currently encourage or at least make it too easy to assume paths like:
      • ...Identity.Profile.DisplayName
    • Based on the current data model, profile attributes are under:
      • ...Identity.Profile.Attributes.<Name>
    • Please fix all affected examples accordingly, especially DisplayName.
  3. Document View behavior more explicitly in troubleshooting.

    • Please explain that profile Views are deterministic convenience views, not raw source-of-truth mirrors.
    • Document the practical consequence of the ordering/selection rule:
      • global/profile Views are built deterministically
      • when multiple profile contributions exist, selection is effectively last-writer-wins based on the defined stable ordering
    • Add a short troubleshooting note explaining why:
      • Request.Context.Views.Identity.Profile
        may legitimately differ from a specific scoped source-of-truth path such as
      • Request.Context.Providers.<Provider>.<AuthSession>.Identity.Profile
  4. Document the correct consumption model for entitlements.

    • Make it explicit that entitlement outputs are lists of objects.
    • Show that they are intended to be consumed through path traversal / conditions such as:
      • ...Entitlements.Id
      • ...Entitlements.DisplayName
      • ...Entitlements.SourceProvider
      • ...Entitlements.SourceAuthSessionName
    • Also state that whole-list template interpolation is intentionally not supported.

Required example changes

  1. Update the context-related example workflow(s).
    • Please add or extend an example that demonstrates:
      • IdLE.Identity.Read
      • IdLE.Entitlement.List
      • a scoped source-of-truth path check
      • a View path check
      • a Contains condition over ...Entitlements.Id (or another scalar entitlement property)
    • Add an inline comment that complex resolver outputs should be inspected host-side via ConvertTo-Json, not rendered directly with {{...}}.

Why this is important

The current implementation now exposes the correct scoped paths and Views from #253, but the documentation is not yet precise enough to prevent predictable authoring mistakes:

  • assuming Profile.DisplayName instead of Profile.Attributes.DisplayName
  • expecting templates to stringify complex resolver outputs
  • not understanding why a View can differ from a specific scoped source-of-truth path

This is now a docs/examples completeness issue, not primarily an implementation issue.

…s path, View determinism note

Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 13, 2026

@copilot Request changes

The implementation looks close, but the current docs/examples still leave important authoring and troubleshooting gaps around the new scoped ContextResolver outputs from #253...

Done in 19b2151. Three changes to docs/use/workflows/context-resolver.md:

  1. Troubleshooting / Inspecting resolved context data — updated to ConvertTo-Json -Depth 8, added targeted $plan.Request.Context.Providers | ConvertTo-Json -Depth 8 and $plan.Request.Context.Views | ConvertTo-Json -Depth 8 inspection commands, and added an explicit note that template substitution is scalar-only and is not the mechanism for logging whole objects or lists.

  2. Profile path examplesProfile.DisplayName changed to Profile.Attributes.DisplayName throughout. Added a new "Profile path not found in Condition" troubleshooting entry stating that attributes are nested under Attributes, not promoted to top level.

  3. View determinism — added an :::info admonition under the IdLE.Identity.Read view section explaining that profile Views are deterministic convenience aggregations (last-writer-wins, sorted alphabetically by provider alias then auth key), not mirrors of a specific provider result, with guidance on when to use Views vs scoped source-of-truth paths. Also added a "View differs from source-of-truth path" troubleshooting entry showing how to use SourceProvider/SourceAuthSessionName to identify the origin.

Copilot AI requested a review from blindzero March 13, 2026 13:39
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1:386

  • Request.Context.Current is cleaned up in the normal precondition pass/fail flow, but if Assert-IdleConditionPathsResolvable or Test-IdleCondition throws, the function will exit without removing Current from the request context. This can leak a step-scoped alias into the caller’s request/plan object and potentially affect subsequent logic if the exception is caught upstream. Consider wrapping the precondition evaluation block in a try/finally so Current is always removed when $currentContextSet is true.
        # 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]) {
                # Fail closed: a malformed or unexpected node type is treated as a failed precondition.
                $preconditionPassed = $false
            }
            else {
                # Validate that all non-Exists paths exist at execution time.
                # Exists operator paths are excluded because Exists semantics intentionally allow missing paths.
                Assert-IdleConditionPathsResolvable -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext -StepName $stepName -Source 'Precondition' -ExcludeExistsOperatorPaths
                if (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) {
                    $preconditionPassed = $false
                }
            }

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@blindzero blindzero merged commit 5ede525 into main Mar 13, 2026
8 checks passed
Copilot AI added a commit that referenced this pull request Mar 13, 2026
Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com>
blindzero added a commit that referenced this pull request Mar 13, 2026
…r-paths

Post-#259 cleanup: correct ContextResolver paths, Views, and attribute structure across docs and tests
@blindzero blindzero deleted the copilot/fix-context-resolvers-namespace branch March 22, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ContextResolvers: Provider/Auth-scoped Context Namespaces + Deterministic Views without wildcards

4 participants