From 3cecd02d6a783939a3a093a8b0ec7fe14ea20983 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:36:39 +0000 Subject: [PATCH 1/8] Initial plan From ba4f4e89d0adee2edda6615ea3013b928bf1cf65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:41:22 +0000 Subject: [PATCH 2/8] Implement Contains, NotContains, Like, and NotLike operators for Condition DSL Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- src/IdLE.Core/Private/Test-IdleCondition.ps1 | 116 ++++- .../Private/Test-IdleConditionSchema.ps1 | 122 ++++- tests/Core/Test-IdleCondition.Tests.ps1 | 435 ++++++++++++++++++ 3 files changed, 660 insertions(+), 13 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleCondition.ps1 b/src/IdLE.Core/Private/Test-IdleCondition.ps1 index 9708a514..8c4367c9 100644 --- a/src/IdLE.Core/Private/Test-IdleCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleCondition.ps1 @@ -16,10 +16,14 @@ function Test-IdleCondition { # Supported schema (validated by Test-IdleConditionSchema): # - Groups: All | Any | None (each contains an array/list of condition nodes) # - Operators: - # - Equals = @{ Path = ''; Value = } - # - NotEquals = @{ Path = ''; Value = } - # - Exists = '' OR @{ Path = '' } - # - In = @{ Path = ''; Values = } + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Path = ''; Values = } + # - Contains = @{ Path = ''; Value = } + # - NotContains = @{ Path = ''; Value = } + # - Like = @{ Path = ''; Pattern = } + # - NotLike = @{ Path = ''; Pattern = } # # Paths are resolved via Get-IdleValueByPath against the provided $Context. # For readability in configuration, a leading "context." prefix is ignored. @@ -139,6 +143,110 @@ function Test-IdleCondition { return $false } + if ($Node.Contains('Contains')) { + $op = $Node.Contains + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + # Contains requires the resolved path to be a list. + if ($null -eq $actual) { + return $false + } + + if (-not ($actual -is [System.Collections.IEnumerable]) -or ($actual -is [string])) { + throw [System.ArgumentException]::new( + ("Contains operator requires Path to resolve to a list, but got '{0}'." -f $actual.GetType().Name), + 'Condition' + ) + } + + # Check if any element in the list matches the expected value (case-insensitive). + foreach ($item in @($actual)) { + if ([string]$item -eq [string]$expected) { + return $true + } + } + + return $false + } + + if ($Node.Contains('NotContains')) { + $op = $Node.NotContains + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $expected = $op.Value + + # NotContains requires the resolved path to be a list. + if ($null -eq $actual) { + return $true + } + + if (-not ($actual -is [System.Collections.IEnumerable]) -or ($actual -is [string])) { + throw [System.ArgumentException]::new( + ("NotContains operator requires Path to resolve to a list, but got '{0}'." -f $actual.GetType().Name), + 'Condition' + ) + } + + # Check if no element in the list matches the expected value (case-insensitive). + foreach ($item in @($actual)) { + if ([string]$item -eq [string]$expected) { + return $false + } + } + + return $true + } + + if ($Node.Contains('Like')) { + $op = $Node.Like + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $pattern = $op.Pattern + + if ($null -eq $actual) { + return $false + } + + # If the value is a list, return true if ANY element matches the pattern. + if (($actual -is [System.Collections.IEnumerable]) -and -not ($actual -is [string])) { + foreach ($item in @($actual)) { + if ([string]$item -like [string]$pattern) { + return $true + } + } + return $false + } + + # Scalar: direct pattern match (case-insensitive by default). + return ([string]$actual -like [string]$pattern) + } + + if ($Node.Contains('NotLike')) { + $op = $Node.NotLike + + $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path) + $pattern = $op.Pattern + + if ($null -eq $actual) { + return $true + } + + # If the value is a list, return true if NO element matches the pattern. + if (($actual -is [System.Collections.IEnumerable]) -and -not ($actual -is [string])) { + foreach ($item in @($actual)) { + if ([string]$item -like [string]$pattern) { + return $false + } + } + return $true + } + + # Scalar: direct pattern non-match (case-insensitive by default). + return ([string]$actual -notlike [string]$pattern) + } + # Should never happen due to schema validation. return $false } diff --git a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 index a943a416..3cc92955 100644 --- a/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleConditionSchema.ps1 @@ -13,14 +13,18 @@ function Test-IdleConditionSchema { # NOTE: # This validator is intentionally strict: # - Unknown keys are errors (keeps configuration deterministic and toolable). - # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In). + # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In/Contains/NotContains/Like/NotLike). # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here. # # Supported operator shapes: - # - Equals = @{ Path = ''; Value = } - # - NotEquals = @{ Path = ''; Value = } - # - Exists = '' OR @{ Path = '' } - # - In = @{ Path = ''; Values = } + # - Equals = @{ Path = ''; Value = } + # - NotEquals = @{ Path = ''; Value = } + # - Exists = '' OR @{ Path = '' } + # - In = @{ Path = ''; Values = } + # - Contains = @{ Path = ''; Value = } + # - NotContains = @{ Path = ''; Value = } + # - Like = @{ Path = ''; Pattern = } + # - NotLike = @{ Path = ''; Pattern = } $errors = [System.Collections.Generic.List[string]]::new() $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" } @@ -71,7 +75,7 @@ function Test-IdleConditionSchema { } $allowedGroupKeys = @('All', 'Any', 'None') - $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In') + $allowedOpKeys = @('Equals', 'NotEquals', 'Exists', 'In', 'Contains', 'NotContains', 'Like', 'NotLike') $allowedKeys = @($allowedGroupKeys + $allowedOpKeys) $presentGroupKeys = @($allowedGroupKeys | Where-Object { $Node.Contains($_) }) @@ -79,13 +83,13 @@ function Test-IdleConditionSchema { # Enforce: either group OR operator, never both. if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) { - Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In/Contains/NotContains/Like/NotLike), not both." -f $NodePath) return , $nodeErrors } # Enforce: at least one recognized key. if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) { - Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath) + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In/Contains/NotContains/Like/NotLike)." -f $NodePath) return , $nodeErrors } @@ -139,7 +143,7 @@ function Test-IdleConditionSchema { return , $nodeErrors } - # OPERATOR: Exactly one of Equals/NotEquals/Exists/In. + # OPERATOR: Exactly one of Equals/NotEquals/Exists/In/Contains/NotContains/Like/NotLike. $opKey = [string]$presentOpKeys[0] $opVal = $Node[$opKey] $opPath = ("{0}.{1}" -f $NodePath, $opKey) @@ -256,6 +260,106 @@ function Test-IdleConditionSchema { return , $nodeErrors } + + 'Contains' { + # Contains operator: + # Contains = @{ Path = 'context.Identity.Entitlements'; Value = 'CN=Group,OU=Groups,DC=example,DC=com' } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Contains must be a hashtable with keys Path and Value." -f $opPath) + return , $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) + } + + return , $nodeErrors + } + + 'NotContains' { + # NotContains operator: + # NotContains = @{ Path = 'context.Identity.Entitlements'; Value = 'CN=Group,OU=Groups,DC=example,DC=com' } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotContains must be a hashtable with keys Path and Value." -f $opPath) + return , $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Value') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Value')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath) + } + + return , $nodeErrors + } + + 'Like' { + # Like operator: + # Like = @{ Path = 'context.Identity.Profile.DisplayName'; Pattern = '* (Contractor)' } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Like must be a hashtable with keys Path and Pattern." -f $opPath) + return , $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Pattern') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Pattern." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Pattern')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Pattern." -f $opPath) + } + + return , $nodeErrors + } + + 'NotLike' { + # NotLike operator: + # NotLike = @{ Path = 'context.Identity.Entitlements'; Pattern = 'CN=HR-*' } + if (-not ($opVal -is [System.Collections.IDictionary])) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotLike must be a hashtable with keys Path and Pattern." -f $opPath) + return , $nodeErrors + } + + foreach ($k in @($opVal.Keys)) { + if (@('Path', 'Pattern') -notcontains [string]$k) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Pattern." -f $opPath, [string]$k) + } + } + + if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath) + } + + if (-not $opVal.Contains('Pattern')) { + Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Pattern." -f $opPath) + } + + return , $nodeErrors + } } Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey) diff --git a/tests/Core/Test-IdleCondition.Tests.ps1 b/tests/Core/Test-IdleCondition.Tests.ps1 index 544c71d1..0e27e389 100644 --- a/tests/Core/Test-IdleCondition.Tests.ps1 +++ b/tests/Core/Test-IdleCondition.Tests.ps1 @@ -119,6 +119,87 @@ Describe 'Condition DSL (schema + evaluator)' { $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' $errors.Count | Should -BeGreaterThan 0 } + + It 'accepts Contains operator with Path + Value' { + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=Group,OU=Groups,DC=example,DC=com' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'rejects Contains with missing Path' { + $condition = @{ + Contains = @{ + Value = 'Test' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'rejects Contains with missing Value' { + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'accepts NotContains operator with Path + Value' { + $condition = @{ + NotContains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=Group,OU=Groups,DC=example,DC=com' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'accepts Like operator with Path + Pattern' { + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } + + It 'rejects Like with missing Pattern' { + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -BeGreaterThan 0 + } + + It 'accepts NotLike operator with Path + Pattern' { + $condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } + } + + $errors = Test-IdleConditionSchema -Condition $condition -StepName 'Demo' + $errors.Count | Should -Be 0 + } } Context 'Evaluation' { @@ -290,6 +371,360 @@ Describe 'Condition DSL (schema + evaluator)' { { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw } + + It 'returns true when Contains finds value in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + 'CN=Admins,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Contains does not find value in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=Admins,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'throws when Contains is used on scalar value' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Name = 'John Doe' + } + } + } + } + + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Name' + Value = 'John' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw + } + + It 'returns true when NotContains does not find value in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=Admins,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + NotContains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when NotContains finds value in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + NotContains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when Like matches scalar value' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Profile = [pscustomobject]@{ + DisplayName = 'John Doe (Contractor)' + } + } + } + } + } + + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Like does not match scalar value' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Profile = [pscustomobject]@{ + DisplayName = 'John Doe' + } + } + } + } + } + + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when Like matches any element in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=HR-Employees,OU=Groups,DC=example,DC=com' + 'CN=Admins,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when Like does not match any element in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=Admins,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when NotLike does not match scalar value' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Profile = [pscustomobject]@{ + DisplayName = 'John Doe' + } + } + } + } + } + + $condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when NotLike matches scalar value' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Profile = [pscustomobject]@{ + DisplayName = 'John Doe (Contractor)' + } + } + } + } + } + + $condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'returns true when NotLike does not match any element in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=Admins,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'returns false when NotLike matches any element in list' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=Users,OU=Groups,DC=example,DC=com' + 'CN=HR-Employees,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeFalse + } + + It 'Contains is case-insensitive' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Entitlements = @( + 'CN=admins,OU=Groups,DC=example,DC=com' + 'CN=users,OU=Groups,DC=example,DC=com' + ) + } + } + } + } + + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=USERS,OU=Groups,DC=example,DC=com' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } + + It 'Like is case-insensitive' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Profile = [pscustomobject]@{ + DisplayName = 'john doe (contractor)' + } + } + } + } + } + + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (CONTRACTOR)' + } + } + + (Test-IdleCondition -Condition $condition -Context $context) | Should -BeTrue + } } } } From 396fdbaeaf724bce656c115d4a0060d5a6969e72 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:45:06 +0000 Subject: [PATCH 3/8] Fix PSSA warnings (PSUseApprovedVerbs and PSUseConsistentIndentation) Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- .../Private/New-IdleEntraIDAdapter.ps1 | 14 ++++++------- tools/Generate-IdleStepReference.ps1 | 21 +++++++++---------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 index c03bb220..cdebcf02 100644 --- a/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 +++ b/src/IdLE.Provider.EntraID/Private/New-IdleEntraIDAdapter.ps1 @@ -499,13 +499,13 @@ function New-IdleEntraIDAdapter { } $results.Add([pscustomobject]@{ - PSTypeName = 'IdLE.BatchMembershipResult' - RequestId = $resp.id - GroupObjectId = $op.GroupObjectId - Action = $op.Action - Changed = $changed - Error = $errorMsg - }) + PSTypeName = 'IdLE.BatchMembershipResult' + RequestId = $resp.id + GroupObjectId = $op.GroupObjectId + Action = $op.Action + Changed = $changed + Error = $errorMsg + }) } } diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index 36ebf156..b3a71fdb 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -118,7 +118,7 @@ function ConvertTo-IdleMarkdownSafeText { return $normalized.Trim() } -function Ensure-IdleBlankLineBeforeMarkdownLists { +function Add-IdleBlankLineBeforeMarkdownLists { [CmdletBinding()] param( [Parameter(Mandatory)] @@ -161,7 +161,7 @@ function ConvertTo-IdleMdxSafeText { $t = $t -replace '\}', '\}' # Lint-friendly markdown lists. - $t = Ensure-IdleBlankLineBeforeMarkdownLists -Text $t + $t = Add-IdleBlankLineBeforeMarkdownLists -Text $t return $t.Trim() } @@ -529,13 +529,13 @@ function New-IdleStepDocModel { $remarksText = '' if ($null -ne $ex -and ($ex.PSObject.Properties.Name -contains 'Remarks') -and $null -ne $ex.Remarks) { $remarksText = ((@($ex.Remarks) | ForEach-Object { - $r = $_ - if ($null -ne $r -and ($r.PSObject.Properties.Name -contains 'Text') -and $null -ne $r.Text) { - [string]$r.Text - } else { - '' - } - }) -join "`n").Trim() + $r = $_ + if ($null -ne $r -and ($r.PSObject.Properties.Name -contains 'Text') -and $null -ne $r.Text) { + [string]$r.Text + } else { + '' + } + }) -join "`n").Trim() if (-not [string]::IsNullOrWhiteSpace($remarksText)) { $remarksText = ConvertTo-IdleMdxSafeText -Text $remarksText } @@ -841,8 +841,7 @@ foreach ($m in $StepModules) { } else { '' } $repoRootNormalized = [System.IO.Path]::GetFullPath($repoRoot) - $isRepoModule = $loadedModuleBase -and - $loadedModuleBase.StartsWith($repoRootNormalized, [System.StringComparison]::OrdinalIgnoreCase) + $isRepoModule = $loadedModuleBase -and $loadedModuleBase.StartsWith($repoRootNormalized, [System.StringComparison]::OrdinalIgnoreCase) if ($isRepoModule) { Write-Verbose "Step module '$m' already loaded from repo: $($loadedModule.ModuleBase)" From 891e854d7017908b003f3a4f04318178b8b81270 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:47:11 +0000 Subject: [PATCH 4/8] Update documentation for new Contains, NotContains, Like, and NotLike operators Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- docs/use/workflows/conditions.md | 156 ++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 3 deletions(-) diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index 22a96bd7..7db86268 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -110,14 +110,111 @@ This section is the authoritative DSL reference. } ``` +#### Contains + +**For list membership evaluation** (case-insensitive). + +- `Path` must resolve to a list/array +- Returns `true` if the list contains the specified value +- Throws an error if `Path` resolves to a scalar + +```powershell +@{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } +} +``` + +#### NotContains + +**For list non-membership evaluation** (case-insensitive). + +- `Path` must resolve to a list/array +- Returns `true` if the list does not contain the specified value +- Throws an error if `Path` resolves to a scalar + +```powershell +@{ + NotContains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } +} +``` + +#### Like + +**For wildcard pattern matching** (case-insensitive). + +- If `Path` resolves to a **scalar**: matches against the value directly +- If `Path` resolves to a **list**: returns `true` if **any** element matches the pattern +- Uses PowerShell's `-like` operator (supports `*` and `?` wildcards) + +```powershell +# Scalar example +@{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } +} + +# List example +@{ + Like = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } +} +``` + +#### NotLike + +**For wildcard pattern non-matching** (case-insensitive). + +- If `Path` resolves to a **scalar**: returns `true` if the value does not match the pattern +- If `Path` resolves to a **list**: returns `true` if **no** element matches the pattern +- Uses PowerShell's `-notlike` operator (supports `*` and `?` wildcards) + +```powershell +# Scalar example +@{ + NotLike = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } +} + +# List example +@{ + NotLike = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } +} +``` + --- ## Comparison Semantics -- Comparisons are string-based +- All comparisons are **case-insensitive** by default +- String-based comparisons for `Equals`, `NotEquals`, `In`, `Contains`, `NotContains` +- Pattern matching for `Like` and `NotLike` uses PowerShell's `-like` operator - Deterministic evaluation - Values are converted to string before comparison +### List vs Scalar Behavior + +| Operator | Scalar Path | List Path | +|----------|-------------|-----------| +| `Contains` | ❌ Error (must be list) | ✅ Check if value in list | +| `NotContains` | ❌ Error (must be list) | ✅ Check if value not in list | +| `Like` | ✅ Match against value | ✅ Match if **any** element matches | +| `NotLike` | ✅ Check value doesn't match | ✅ Check **no** element matches | + --- ## Validation Rules @@ -165,6 +262,55 @@ Condition = @{ } ``` +### Only if not member of a specific group (NotContains) + +```powershell +Condition = @{ + NotContains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + } +} +``` + +### Only if not member of any HR group (NotLike) + +```powershell +Condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Entitlements' + Pattern = 'CN=HR-*' + } +} +``` + +### Only for contractors (Like with scalar) + +```powershell +Condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Profile.DisplayName' + Pattern = '* (Contractor)' + } +} +``` + +### Guard destructive step (combine NotContains with lifecycle check) + +```powershell +Condition = @{ + All = @( + @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + @{ + NotContains = @{ + Path = 'Request.Context.Identity.Entitlements' + Value = 'CN=Protected-Accounts,OU=Groups,DC=example,DC=com' + } + } + ) +} +``` + --- ## Troubleshooting @@ -179,15 +325,19 @@ Condition = @{ Each node may contain exactly one of: - a group: `All`, `Any`, `None` -- an operator: `Equals`, `NotEquals`, `Exists`, `In` +- an operator: `Equals`, `NotEquals`, `Exists`, `In`, `Contains`, `NotContains`, `Like`, `NotLike` Any additional keys cause a planning-time validation error. ### Planning fails with “Missing or empty Path” -Operators like `Equals`, `NotEquals`, and `In` require a non-empty `Path`. +All operators require a non-empty `Path`. For `Exists`, prefer the short form `Exists = '…'` to avoid shape errors. +### Planning fails with "Contains operator requires Path to resolve to a list" + +`Contains` and `NotContains` only work with list/array paths. If you need to check a scalar value, use `Equals` or `Like` instead. + ### Confusion about “Skipped” Conditions do not “skip” execution. They decide applicability during planning and mark the step as `NotApplicable`. From b80ff9c23c37c545cec1aeac7495fb83de19404c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:18:05 +0000 Subject: [PATCH 5/8] Add explicit hashtable/dictionary rejection for all new operators Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- src/IdLE.Core/Private/Test-IdleCondition.ps1 | 32 +++++++ tests/Core/Test-IdleCondition.Tests.ps1 | 96 ++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/src/IdLE.Core/Private/Test-IdleCondition.ps1 b/src/IdLE.Core/Private/Test-IdleCondition.ps1 index 8c4367c9..d991f0b7 100644 --- a/src/IdLE.Core/Private/Test-IdleCondition.ps1 +++ b/src/IdLE.Core/Private/Test-IdleCondition.ps1 @@ -154,6 +154,14 @@ function Test-IdleCondition { return $false } + # Reject dictionaries/hashtables explicitly (they implement IEnumerable but are not lists). + if ($actual -is [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Contains operator requires Path to resolve to a list/array, not a hashtable/dictionary."), + 'Condition' + ) + } + if (-not ($actual -is [System.Collections.IEnumerable]) -or ($actual -is [string])) { throw [System.ArgumentException]::new( ("Contains operator requires Path to resolve to a list, but got '{0}'." -f $actual.GetType().Name), @@ -182,6 +190,14 @@ function Test-IdleCondition { return $true } + # Reject dictionaries/hashtables explicitly (they implement IEnumerable but are not lists). + if ($actual -is [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("NotContains operator requires Path to resolve to a list/array, not a hashtable/dictionary."), + 'Condition' + ) + } + if (-not ($actual -is [System.Collections.IEnumerable]) -or ($actual -is [string])) { throw [System.ArgumentException]::new( ("NotContains operator requires Path to resolve to a list, but got '{0}'." -f $actual.GetType().Name), @@ -209,6 +225,14 @@ function Test-IdleCondition { return $false } + # Reject dictionaries/hashtables explicitly to avoid ambiguous iteration over keys/entries. + if ($actual -is [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("Like operator cannot evaluate a hashtable/dictionary. Use a list/array or scalar value."), + 'Condition' + ) + } + # If the value is a list, return true if ANY element matches the pattern. if (($actual -is [System.Collections.IEnumerable]) -and -not ($actual -is [string])) { foreach ($item in @($actual)) { @@ -233,6 +257,14 @@ function Test-IdleCondition { return $true } + # Reject dictionaries/hashtables explicitly to avoid ambiguous iteration over keys/entries. + if ($actual -is [System.Collections.IDictionary]) { + throw [System.ArgumentException]::new( + ("NotLike operator cannot evaluate a hashtable/dictionary. Use a list/array or scalar value."), + 'Condition' + ) + } + # If the value is a list, return true if NO element matches the pattern. if (($actual -is [System.Collections.IEnumerable]) -and -not ($actual -is [string])) { foreach ($item in @($actual)) { diff --git a/tests/Core/Test-IdleCondition.Tests.ps1 b/tests/Core/Test-IdleCondition.Tests.ps1 index 0e27e389..fa176075 100644 --- a/tests/Core/Test-IdleCondition.Tests.ps1 +++ b/tests/Core/Test-IdleCondition.Tests.ps1 @@ -442,6 +442,102 @@ Describe 'Condition DSL (schema + evaluator)' { { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw } + It 'throws when Contains is used on hashtable' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Metadata = @{ + Department = 'Engineering' + Location = 'Seattle' + } + } + } + } + } + + $condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Metadata' + Value = 'Engineering' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw -ExpectedMessage '*hashtable/dictionary*' + } + + It 'throws when NotContains is used on hashtable' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Metadata = @{ + Department = 'Engineering' + Location = 'Seattle' + } + } + } + } + } + + $condition = @{ + NotContains = @{ + Path = 'Request.Context.Identity.Metadata' + Value = 'HR' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw -ExpectedMessage '*hashtable/dictionary*' + } + + It 'throws when Like is used on hashtable' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Metadata = @{ + Department = 'Engineering' + Location = 'Seattle' + } + } + } + } + } + + $condition = @{ + Like = @{ + Path = 'Request.Context.Identity.Metadata' + Pattern = 'Eng*' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw -ExpectedMessage '*hashtable/dictionary*' + } + + It 'throws when NotLike is used on hashtable' { + $context = [pscustomobject]@{ + Request = [pscustomobject]@{ + Context = [pscustomobject]@{ + Identity = [pscustomobject]@{ + Metadata = @{ + Department = 'Engineering' + Location = 'Seattle' + } + } + } + } + } + + $condition = @{ + NotLike = @{ + Path = 'Request.Context.Identity.Metadata' + Pattern = 'HR*' + } + } + + { Test-IdleCondition -Condition $condition -Context $context } | Should -Throw -ExpectedMessage '*hashtable/dictionary*' + } + It 'returns true when NotContains does not find value in list' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ From 521fdfedc71d6d7b956af5a7c91d07676e009232 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:37:57 +0000 Subject: [PATCH 6/8] Add member-access enumeration to support entitlement object properties Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- docs/use/workflows/conditions.md | 52 +++++-- .../Private/Get-IdlePropertyValue.ps1 | 35 +++++ tests/Core/Get-IdlePropertyValue.Tests.ps1 | 138 ++++++++++++++++++ tests/Core/Test-IdleCondition.Tests.ps1 | 58 ++++---- 4 files changed, 244 insertions(+), 39 deletions(-) create mode 100644 tests/Core/Get-IdlePropertyValue.Tests.ps1 diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index 7db86268..aec6fa60 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -119,14 +119,17 @@ This section is the authoritative DSL reference. - Throws an error if `Path` resolves to a scalar ```powershell +# Check if a specific group DN is in the entitlements @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } ``` +> **Note**: When `Request.Context.Identity.Entitlements` contains objects (e.g., `@{ Kind = 'Group'; Id = '...'; DisplayName = '...' }`), use `.Id` or `.DisplayName` to extract the property values: `Entitlements.Id` returns an array of all Id values. + #### NotContains **For list non-membership evaluation** (case-insensitive). @@ -136,9 +139,10 @@ This section is the authoritative DSL reference. - Throws an error if `Path` resolves to a scalar ```powershell +# Prevent execution if identity has a specific group @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -153,7 +157,7 @@ This section is the authoritative DSL reference. - Uses PowerShell's `-like` operator (supports `*` and `?` wildcards) ```powershell -# Scalar example +# Scalar example: check if DisplayName contains "Contractor" @{ Like = @{ Path = 'Request.Context.Identity.Profile.DisplayName' @@ -161,15 +165,17 @@ This section is the authoritative DSL reference. } } -# List example +# List example: check if any entitlement Id matches the pattern @{ Like = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } ``` +> **Note**: When checking entitlement Ids or DisplayNames, use `.Id` or `.DisplayName` to extract property values from entitlement objects. The path `Entitlements.Id` uses member-access enumeration to return an array of all Id values. + #### NotLike **For wildcard pattern non-matching** (case-insensitive). @@ -187,10 +193,10 @@ This section is the authoritative DSL reference. } } -# List example +# List example: ensure no HR groups in entitlements @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -206,6 +212,32 @@ This section is the authoritative DSL reference. - Deterministic evaluation - Values are converted to string before comparison +### Member-Access Enumeration + +When a `Path` points to a list of objects, you can access properties of those objects using dot notation: + +- `Request.Context.Identity.Entitlements` → returns array of entitlement objects +- `Request.Context.Identity.Entitlements.Id` → returns array of all `Id` values +- `Request.Context.Identity.Entitlements.DisplayName` → returns array of all `DisplayName` values + +**Example**: +```powershell +# Entitlements contains: @( +# @{ Kind = 'Group'; Id = 'CN=Users,...'; DisplayName = 'Users' } +# @{ Kind = 'Group'; Id = 'CN=Admins,...'; DisplayName = 'Admins' } +# ) + +# Check if any entitlement Id matches a pattern +@{ + Like = @{ + Path = 'Request.Context.Identity.Entitlements.Id' + Pattern = 'CN=HR-*' + } +} +``` + +This follows PowerShell's native member-access enumeration behavior. + ### List vs Scalar Behavior | Operator | Scalar Path | List Path | @@ -267,7 +299,7 @@ Condition = @{ ```powershell Condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -278,7 +310,7 @@ Condition = @{ ```powershell Condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -303,7 +335,7 @@ Condition = @{ @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=Protected-Accounts,OU=Groups,DC=example,DC=com' } } diff --git a/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 b/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 index 7d55df18..1423abc3 100644 --- a/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 +++ b/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 @@ -23,10 +23,45 @@ function Get-IdlePropertyValue { return $null } + # Check for direct property first (takes precedence over member-access enumeration) $prop = $Object.PSObject.Properties[$Name] if ($null -ne $prop) { return $prop.Value } + # Support member-access enumeration: if Object is an array/list and items have the property, + # return an array of all property values (mimics PowerShell's native behavior). + if (($Object -is [System.Collections.IEnumerable]) -and -not ($Object -is [string])) { + $items = @($Object) + if ($items.Count -gt 0) { + # Check if the first item has the property + $firstItem = $items[0] + if ($null -ne $firstItem) { + $testProp = if ($firstItem -is [System.Collections.IDictionary]) { + if ($firstItem.Contains($Name)) { $Name } else { $null } + } else { + if ($null -ne $firstItem.PSObject.Properties[$Name]) { $Name } else { $null } + } + + if ($null -ne $testProp) { + # Extract the property from all items + $result = @() + foreach ($item in $items) { + if ($null -ne $item) { + $val = if ($item -is [System.Collections.IDictionary]) { + $item[$Name] + } else { + $p = $item.PSObject.Properties[$Name] + if ($null -ne $p) { $p.Value } else { $null } + } + $result += $val + } + } + return $result + } + } + } + } + return $null } diff --git a/tests/Core/Get-IdlePropertyValue.Tests.ps1 b/tests/Core/Get-IdlePropertyValue.Tests.ps1 new file mode 100644 index 00000000..2d22b39a --- /dev/null +++ b/tests/Core/Get-IdlePropertyValue.Tests.ps1 @@ -0,0 +1,138 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + . (Join-Path $PSScriptRoot '../../src/IdLE.Core/Private/Get-IdlePropertyValue.ps1') +} + +Describe 'Get-IdlePropertyValue' { + + Context 'Basic property access' { + It 'returns property value from PSCustomObject' { + $obj = [pscustomobject]@{ Name = 'John'; Age = 30 } + $result = Get-IdlePropertyValue -Object $obj -Name 'Name' + $result | Should -Be 'John' + } + + It 'returns property value from hashtable' { + $obj = @{ Name = 'Jane'; Age = 25 } + $result = Get-IdlePropertyValue -Object $obj -Name 'Name' + $result | Should -Be 'Jane' + } + + It 'returns null for non-existent property' { + $obj = [pscustomobject]@{ Name = 'John' } + $result = Get-IdlePropertyValue -Object $obj -Name 'Missing' + $result | Should -BeNullOrEmpty + } + + It 'returns null when Object is null' { + $result = Get-IdlePropertyValue -Object $null -Name 'Name' + $result | Should -BeNullOrEmpty + } + } + + Context 'Member-access enumeration (array property access)' { + It 'extracts property from all array items' { + $list = @( + [pscustomobject]@{ Kind = 'Group'; Id = 'g1'; DisplayName = 'Group 1' } + [pscustomobject]@{ Kind = 'Group'; Id = 'g2'; DisplayName = 'Group 2' } + [pscustomobject]@{ Kind = 'Group'; Id = 'g3'; DisplayName = 'Group 3' } + ) + + $result = Get-IdlePropertyValue -Object $list -Name 'Id' + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 3 + $result[0] | Should -Be 'g1' + $result[1] | Should -Be 'g2' + $result[2] | Should -Be 'g3' + } + + It 'extracts DisplayName from entitlement objects' { + $list = @( + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,DC=example,DC=com'; DisplayName = 'Admins' } + ) + + $result = Get-IdlePropertyValue -Object $list -Name 'DisplayName' + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result[0] | Should -Be 'Users' + $result[1] | Should -Be 'Admins' + } + + It 'returns null when array items do not have the property' { + $list = @( + [pscustomobject]@{ Name = 'John' } + [pscustomobject]@{ Name = 'Jane' } + ) + + $result = Get-IdlePropertyValue -Object $list -Name 'Missing' + + $result | Should -BeNullOrEmpty + } + + It 'returns null for empty array' { + $list = @() + + $result = Get-IdlePropertyValue -Object $list -Name 'Id' + + $result | Should -BeNullOrEmpty + } + + It 'handles arrays with null items gracefully' { + $list = @( + [pscustomobject]@{ Id = 'g1' } + $null + [pscustomobject]@{ Id = 'g3' } + ) + + $result = Get-IdlePropertyValue -Object $list -Name 'Id' + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result[0] | Should -Be 'g1' + $result[1] | Should -Be 'g3' + } + + It 'extracts property from hashtable array items' { + $list = @( + @{ Kind = 'Group'; Id = 'g1' } + @{ Kind = 'Group'; Id = 'g2' } + ) + + $result = Get-IdlePropertyValue -Object $list -Name 'Id' + + $result | Should -Not -BeNullOrEmpty + $result.Count | Should -Be 2 + $result[0] | Should -Be 'g1' + $result[1] | Should -Be 'g2' + } + } + + Context 'Edge cases' { + It 'does not enumerate strings as character arrays' { + $obj = [pscustomobject]@{ Name = 'Hello' } + $result = Get-IdlePropertyValue -Object $obj -Name 'Name' + + $result | Should -Be 'Hello' + $result | Should -BeOfType [string] + } + + It 'returns scalar property when object has both the property and is enumerable' { + # Edge case: object has both a direct property AND is enumerable + $obj = New-Object System.Collections.ArrayList + $obj.Add([pscustomobject]@{ Id = 'item1' }) | Out-Null + $obj | Add-Member -NotePropertyName 'CustomProp' -NotePropertyValue 'CustomValue' + + # Should return the direct property, not enumerate + $result = Get-IdlePropertyValue -Object $obj -Name 'CustomProp' + + $result | Should -Be 'CustomValue' + } + } +} diff --git a/tests/Core/Test-IdleCondition.Tests.ps1 b/tests/Core/Test-IdleCondition.Tests.ps1 index fa176075..09acc3bf 100644 --- a/tests/Core/Test-IdleCondition.Tests.ps1 +++ b/tests/Core/Test-IdleCondition.Tests.ps1 @@ -378,9 +378,9 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' - 'CN=Admins,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com'; DisplayName = 'BreakGlass Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } ) } } @@ -389,7 +389,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -403,8 +403,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=Admins,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } ) } } @@ -413,7 +413,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -544,8 +544,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=Admins,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } ) } } @@ -554,7 +554,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -568,8 +568,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com'; DisplayName = 'BreakGlass Users' } ) } } @@ -578,7 +578,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -638,9 +638,9 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=HR-Employees,OU=Groups,DC=example,DC=com' - 'CN=Admins,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=HR-Employees,OU=Groups,DC=example,DC=com'; DisplayName = 'HR Employees' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } ) } } @@ -649,7 +649,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -663,8 +663,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=Admins,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } ) } } @@ -673,7 +673,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -733,8 +733,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=Admins,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } ) } } @@ -743,7 +743,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -757,8 +757,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=Users,OU=Groups,DC=example,DC=com' - 'CN=HR-Employees,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=Users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=HR-Employees,OU=Groups,DC=example,DC=com'; DisplayName = 'HR Employees' } ) } } @@ -767,7 +767,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -781,8 +781,8 @@ Describe 'Condition DSL (schema + evaluator)' { Context = [pscustomobject]@{ Identity = [pscustomobject]@{ Entitlements = @( - 'CN=admins,OU=Groups,DC=example,DC=com' - 'CN=users,OU=Groups,DC=example,DC=com' + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=admins,OU=Groups,DC=example,DC=com'; DisplayName = 'Admins' } + [pscustomobject]@{ Kind = 'Group'; Id = 'CN=users,OU=Groups,DC=example,DC=com'; DisplayName = 'Users' } ) } } @@ -791,7 +791,7 @@ Describe 'Condition DSL (schema + evaluator)' { $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Identity.Entitlements.Id' Value = 'CN=USERS,OU=Groups,DC=example,DC=com' } } From d5c66a773ffdbf511dac81f735a483205330c3ce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:40:03 +0000 Subject: [PATCH 7/8] Improve performance and test coverage for member-access enumeration Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 | 6 +++--- tests/Core/Get-IdlePropertyValue.Tests.ps1 | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 b/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 index 1423abc3..a145b021 100644 --- a/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 +++ b/src/IdLE.Core/Private/Get-IdlePropertyValue.ps1 @@ -45,7 +45,7 @@ function Get-IdlePropertyValue { if ($null -ne $testProp) { # Extract the property from all items - $result = @() + $result = [System.Collections.Generic.List[object]]::new() foreach ($item in $items) { if ($null -ne $item) { $val = if ($item -is [System.Collections.IDictionary]) { @@ -54,10 +54,10 @@ function Get-IdlePropertyValue { $p = $item.PSObject.Properties[$Name] if ($null -ne $p) { $p.Value } else { $null } } - $result += $val + $result.Add($val) } } - return $result + return $result.ToArray() } } } diff --git a/tests/Core/Get-IdlePropertyValue.Tests.ps1 b/tests/Core/Get-IdlePropertyValue.Tests.ps1 index 2d22b39a..a8c7510b 100644 --- a/tests/Core/Get-IdlePropertyValue.Tests.ps1 +++ b/tests/Core/Get-IdlePropertyValue.Tests.ps1 @@ -28,6 +28,12 @@ Describe 'Get-IdlePropertyValue' { $result | Should -BeNullOrEmpty } + It 'returns null for non-existent key in hashtable' { + $obj = @{ Name = 'John' } + $result = Get-IdlePropertyValue -Object $obj -Name 'Missing' + $result | Should -BeNullOrEmpty + } + It 'returns null when Object is null' { $result = Get-IdlePropertyValue -Object $null -Name 'Name' $result | Should -BeNullOrEmpty From 4fb4c68d8e6116e3bb50b51a48409caadc186857 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:50:45 +0000 Subject: [PATCH 8/8] Add troubleshooting guidance for inspecting resolved context data Co-authored-by: ntt-matthias-fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> --- docs/reference/capabilities.md | 13 +++++++ docs/use/workflows/context-resolver.md | 48 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 594ee3c4..976c7f1e 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -143,6 +143,8 @@ Each capability writes to a **predefined, fixed path** under `Request.Context`. | `IdLE.Entitlement.List` | `Request.Context.Identity.Entitlements` | `IdentityKey` (string) | | `IdLE.Identity.Read` | `Request.Context.Identity.Profile` | `IdentityKey` (string) | +> **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). + ### Example ```powershell @@ -166,5 +168,16 @@ ContextResolvers = @( Steps can then reference the resolved data in their `Condition`: ```powershell +# Check if entitlements exist Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } + +# Check if a specific group Id is present +Condition = @{ + Contains = @{ + Path = 'Request.Context.Identity.Entitlements.Id' + Value = 'CN=Admins,OU=Groups,DC=example,DC=com' + } +} ``` + +> **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). diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index d3409e18..aa020be8 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -171,3 +171,51 @@ Condition = @{ Exists = 'Request.Context.Identity.Entitlements' } ### Type conflict in context path - A resolver cannot overwrite an existing path with incompatible type. + +### 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. + +**Method 1: Inspect the plan object after planning** + +```powershell +$plan = New-IdlePlan -WorkflowPath ./workflow.psd1 -Request $req -Providers $providers + +# View the entire context structure +$plan.Request.Context | ConvertTo-Json -Depth 5 + +# View specific resolved data +$plan.Request.Context.Identity.Entitlements | ConvertTo-Json -Depth 2 +``` + +**Method 2: Use Format-Table for quick inspection** + +```powershell +# After planning, inspect entitlements structure +$plan.Request.Context.Identity.Entitlements | Format-Table -AutoSize +``` + +**Method 3: Access individual properties** + +```powershell +# Check if entitlements are objects with properties +$plan.Request.Context.Identity.Entitlements[0] | Get-Member +$plan.Request.Context.Identity.Entitlements[0].Id +$plan.Request.Context.Identity.Entitlements[0].DisplayName +``` + +**Using discovered structure in Conditions** + +Once you know the structure (e.g., entitlements are objects with `Kind`, `Id`, `DisplayName`), use member-access enumeration in your condition paths: + +```powershell +# Extract Id values from all entitlement objects +Condition = @{ + NotContains = @{ + Path = 'Request.Context.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.