Skip to content
13 changes: 13 additions & 0 deletions docs/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
188 changes: 185 additions & 3 deletions docs/use/workflows/conditions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
title: Conditions
sidebar_label: Conditions
Expand Down Expand Up @@ -110,14 +110,143 @@
}
```

#### 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
# Check if a specific group DN is in the entitlements
@{
Contains = @{
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).

- `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
# Prevent execution if identity has a specific group
@{
NotContains = @{
Path = 'Request.Context.Identity.Entitlements.Id'
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: check if DisplayName contains "Contractor"
@{
Like = @{
Path = 'Request.Context.Identity.Profile.DisplayName'
Pattern = '* (Contractor)'
}
}

# List example: check if any entitlement Id matches the pattern
@{
Like = @{
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).

- 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: ensure no HR groups in entitlements
@{
NotLike = @{
Path = 'Request.Context.Identity.Entitlements.Id'
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

### 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 |
|----------|-------------|-----------|
| `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
Expand Down Expand Up @@ -165,6 +294,55 @@
}
```

### Only if not member of a specific group (NotContains)

```powershell
Condition = @{
NotContains = @{
Path = 'Request.Context.Identity.Entitlements.Id'
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.Id'
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.Id'
Value = 'CN=Protected-Accounts,OU=Groups,DC=example,DC=com'
}
}
)
}
```

---

## Troubleshooting
Expand All @@ -179,15 +357,19 @@
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`.
48 changes: 48 additions & 0 deletions docs/use/workflows/context-resolver.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions src/IdLE.Core/Private/Get-IdlePropertyValue.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [System.Collections.Generic.List[object]]::new()
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.Add($val)
}
}
return $result.ToArray()
}
}
}
}

return $null
}
Loading