Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bb6bea4
Initial plan
Copilot Mar 2, 2026
588ae50
Flatten Identity.Profile attributes for direct template access
Copilot Mar 2, 2026
e7cd3c5
Add edge case tests and verbose warning for attribute conflicts
Copilot Mar 2, 2026
6e6b5f3
Improve documentation for reserved property names in flattening
Copilot Mar 2, 2026
7e6b18c
Remove testResults.xml and update .gitignore
Copilot Mar 2, 2026
cb0cc8e
Apply suggestions from code review
ntt-matthias-fleschuetz Mar 2, 2026
e9199e9
Preserve PSTypeName when flattening and copying identity objects
Copilot Mar 2, 2026
026b580
Update context-resolver documentation for attribute flattening
Copilot Mar 2, 2026
bd2ae46
Add provider selection and authentication section to context-resolver…
Copilot Mar 2, 2026
560194e
Remove backward compatibility for Attributes hashtable and add preced…
Copilot Mar 2, 2026
7545094
Update provider documentation to clarify no backward compatibility fo…
Copilot Mar 2, 2026
8a8040d
Improve documentation clarity for reserved property names
Copilot Mar 2, 2026
c78359c
Simplify context-resolver documentation with comprehensive inline exa…
Copilot Mar 2, 2026
ef5cb50
Merge branch 'main' into copilot/fix-template-substitution-error
ntt-matthias-fleschuetz Mar 13, 2026
bab5448
test(core): remove attribute flattening and align tests with scoped c…
Copilot Mar 13, 2026
aa520a4
test(core): fix Test-IdleCondition mock context structures for Views …
Copilot Mar 13, 2026
a5994c1
removed stale file
blindzero Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ coverage.*
test-results.*
*.lcov
testresults.xml
testResults.xml
*.trx
docs-audit.json

# Packages
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ For `IdLE.Identity.Read`, the engine additionally builds (single object — last
> When multiple providers or sessions contribute to a view scope, the profile from the last entry
> in sort order (provider alias ascending, then auth key ascending) is used.

> **Note**: `IdLE.Identity.Read` automatically flattens identity attributes to the top level of `Request.Context.Identity.Profile`. You can access attributes directly (e.g., `Request.Context.Identity.Profile.DisplayName`) instead of via the nested path (e.g., `Request.Context.Identity.Profile.Attributes.DisplayName`). The `Attributes` hashtable is preserved for backwards compatibility. See [Context Resolvers - Identity Profile Attribute Flattening](../use/workflows/context-resolver.md#identity-profile-attribute-flattening) for details.

### Example

```powershell
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/providers/provider-ad.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Top-level properties:
| `Enabled` | `bool` | Derived from AD user `Enabled`. |
| `Attributes` | `hashtable` | Key/value bag; keys are strings; values are typically `string`. |

> **Note**: Identity attributes are automatically flattened to the top level of `Request.Context.Identity.Profile`. You must access attributes directly (e.g., `Profile.DisplayName`). The `Attributes` hashtable is removed after flattening. See [Context Resolvers - Identity Profile Attribute Flattening](../../use/workflows/context-resolver.md#identity-profile-attribute-flattening).

`Attributes` keys populated by this provider (when present on the AD user object):

| Attribute key | Type |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/providers/provider-entraID.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ Top-level properties:
| `Enabled` | `bool` | Derived from Entra user `accountEnabled`. |
| `Attributes` | `hashtable` | Key/value bag; keys are strings; values are typically `string`. |

> **Note**: Identity attributes are automatically flattened to the top level of `Request.Context.Identity.Profile`. You must access attributes directly (e.g., `Profile.DisplayName`). The `Attributes` hashtable is removed after flattening. See [Context Resolvers - Identity Profile Attribute Flattening](../../use/workflows/context-resolver.md#identity-profile-attribute-flattening).

`Attributes` keys populated by this provider (when present on the user object):

| Attribute key | Type | Source (Graph field) |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/providers/provider-mock.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ Top-level properties:
| `Enabled` | `bool` | Stored boolean value (defaults to `$true` when created on demand). |
| `Attributes` | `hashtable` | Free-form key/value bag stored in the mock provider store. |

> **Note**: Identity attributes are automatically flattened to the top level of `Request.Context.Identity.Profile`. You must access attributes directly (e.g., `Profile.DisplayName`). The `Attributes` hashtable is removed after flattening. See [Context Resolvers - Identity Profile Attribute Flattening](../../use/workflows/context-resolver.md#identity-profile-attribute-flattening).

Mock-specific behavior:
- Missing identities are created **on-demand** on first `GetIdentity` call (planning-time resolvers may therefore “create” a record in the in-memory store).
- `Attributes` is whatever your tests/demos put into the store (commonly `string` values).
Expand Down
199 changes: 172 additions & 27 deletions docs/use/workflows/context-resolver.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
title: Context Resolvers
sidebar_label: Context Resolvers
Expand Down Expand Up @@ -109,20 +109,26 @@

```powershell
@{
Name = 'Joiner - Context Resolver Demo'
LifecycleEvent = 'Joiner'
Name = 'Offboarding - Context Resolver Example'
LifecycleEvent = 'Leaver'

# ContextResolvers populate Request.Context.* during planning
# They execute sequentially in declaration order BEFORE step conditions are evaluated
ContextResolvers = @(

# Resolver 1: Read identity profile from Active Directory
@{
Capability = 'IdLE.Identity.Read'
Capability = 'IdLE.Identity.Read' # REQUIRED - Must be from allow-list
With = @{
IdentityKey = '{{Request.IdentityKeys.EmployeeId}}'
Provider = 'Identity' # optional; auto-selected if omitted
AuthSessionName = 'Tier0' # optional; requires AuthSessionBroker in Providers
IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' # REQUIRED - Template substitution supported
Provider = 'PrimaryAD' # OPTIONAL - Auto-selected if only one provider matches
AuthSessionName = 'Tier0-AD' # OPTIONAL - Named auth session from AuthSessionBroker
# AuthSessionOptions = @{ Scopes = @('...') } # OPTIONAL - Provider-specific auth options (must be data-only)
}
# Writes to: Request.Context.Providers.Identity.Tier0.Identity.Profile
}

# Resolver 2: List entitlements for the identity
@{
Capability = 'IdLE.Entitlement.List'
With = @{
Expand All @@ -135,9 +141,9 @@
)

Steps = @(

# Step conditions can reference resolved context data
@{
Name = 'Disable only if identity exists'
Name = 'Disable account only if identity exists in AD'
Type = 'IdLE.Step.DisableIdentity'

# Reference the scoped source-of-truth path:
Expand All @@ -146,10 +152,10 @@
}
}

# Template substitution can use flattened attributes
@{
Name = 'Emit audit event'
Name = 'Send notification email'
Type = 'IdLE.Step.EmitEvent'

With = @{
Message = 'Disabled identity {{Request.Context.Providers.Identity.Tier0.Identity.Profile.Attributes.DisplayName}}'
}
Expand All @@ -158,13 +164,18 @@
}
```

### Keys
### Resolver Configuration Keys

- `Capability` (required)
A permitted read-only capability.
| Key | Type | Required | Description |
|-----|------|----------|-------------|
| `Capability` | `string` | **Yes** | Read-only capability from allow-list: `IdLE.Identity.Read`, `IdLE.Entitlement.List` |
| `With` | `hashtable` | **Yes**¹ | Inputs required by the capability. Template substitution supported. |
| `With.IdentityKey` | `string` | **Yes** | Identity key for lookup. Required by both capabilities. |
| `With.Provider` | `string` | No | Provider alias. Auto-selected if omitted and only one provider matches. Required if multiple providers advertise the capability. |
| `With.AuthSessionName` | `string` | No | Named auth session to acquire via `AuthSessionBroker`. |
| `With.AuthSessionOptions` | `hashtable` | No | Provider-specific auth options. Must be data-only (no ScriptBlocks). |

- `With` (hashtable, optional — required in practice, as capabilities need at least `IdentityKey`)
Inputs required by the capability. Template substitution is supported.
¹ Technically optional, but required in practice for all current capabilities.

| `With` key | Type | Required | Description |
|---|---|---|---|
Expand All @@ -175,8 +186,132 @@

---

## Common Patterns
## Provider Selection and Authentication

### Provider Selection

**Auto-selection:** If only one provider advertises the capability, omit `Provider`:

```powershell
With = @{
IdentityKey = '{{Request.IdentityKeys.EmployeeId}}'
# Provider omitted - auto-selected
}
```

**Explicit selection:** Required when multiple providers match:

```powershell
With = @{
IdentityKey = '{{Request.IdentityKeys.EmployeeId}}'
Provider = 'PrimaryAD' # Disambiguates between PrimaryAD and EntraID
}
```

### Authentication

Some providers require authentication via `AuthSessionBroker`:

```powershell
With = @{
IdentityKey = '{{Request.IdentityKeys.EmployeeId}}'
Provider = 'PrimaryAD'
AuthSessionName = 'Tier0' # Named session from AuthSessionBroker
}
```

Advanced auth options (provider-specific):

```powershell
AuthSessionOptions = @{
Scopes = @('User.Read.All', 'Group.Read.All')
}
```

> **Security**: `AuthSessionOptions` must be data-only (no ScriptBlocks).

### Provider-Specific Attributes

Different providers populate different attributes. After flattening, attributes become top-level properties:

- **AD**: `GivenName`, `Surname`, `DisplayName`, `Department`, `Title`, `EmailAddress`, `UserPrincipalName`, `sAMAccountName`, `DistinguishedName`
- **Entra ID**: `GivenName`, `Surname`, `DisplayName`, `UserPrincipalName`, `Mail`, `Department`, `JobTitle`, `OfficeLocation`
- **Mock**: Configurable test attributes

See provider docs for complete lists: [AD](../../reference/providers/provider-ad.md#capability-idleidentityread), [Entra ID](../../reference/providers/provider-entraID.md#capability-idleidentityread), [Mock](../../reference/providers/provider-mock.md#capability-idleidentityread)

---

## Identity Profile Attribute Flattening

Provider identity objects contain an `Attributes` hashtable. **IdLE automatically flattens these to top-level properties** for direct access:

```powershell
# ✅ Direct access (attributes flattened to top level)
'{{Request.Context.Identity.Profile.DisplayName}}'
'{{Request.Context.Identity.Profile.EmailAddress}}'

# ❌ Nested access no longer supported (Attributes removed after flattening)
'{{Request.Context.Identity.Profile.Attributes.DisplayName}}'
```

**Flattened structure:**

```powershell
Request.Context.Identity.Profile = @{
PSTypeName = 'IdLE.Identity' # Preserved from provider
IdentityKey = 'user123' # Core property
Enabled = $true # Core property
DisplayName = 'Jane Doe' # Flattened from Attributes
EmailAddress = 'jane@example.com' # Flattened from Attributes
# ... other attributes as top-level properties
}
```

**Reserved names:** `IdentityKey` and `Enabled` cannot be overwritten by attributes. Conflicts trigger verbose warnings and the attribute is skipped.

---

## Multiple Resolvers and Precedence

Resolvers execute **sequentially in declaration order**. If multiple resolvers write to the same path, **later ones overwrite earlier ones** (last-writer-wins):

```powershell
ContextResolvers = @(
@{ Capability = 'IdLE.Identity.Read'; With = @{ Provider = 'PrimaryAD' } } # Executes first
@{ Capability = 'IdLE.Identity.Read'; With = @{ Provider = 'EntraID' } } # Overwrites Profile with EntraID data
)
# Result: Request.Context.Identity.Profile contains EntraID data only
```

**Using different providers per resolver:**

```powershell
ContextResolvers = @(
@{
Capability = 'IdLE.Identity.Read'
With = @{
IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}'
Provider = 'PrimaryAD'
AuthSessionName = 'Tier0-AD' # On-premises AD auth
}
}
@{
Capability = 'IdLE.Entitlement.List'
With = @{
IdentityKey = '{{Request.IdentityKeys.UserPrincipalName}}'
Provider = 'EntraID'
AuthSessionName = 'GraphAPI' # Cloud auth (different session)
}
}
)
# Result: Profile from AD, Entitlements from EntraID (no conflicts - different paths)
```

**Best practices:**
- Use different capabilities to avoid overwrites (`IdLE.Identity.Read` → `Identity.Profile`, `IdLE.Entitlement.List` → `Identity.Entitlements`)
- If intentional overwrite is needed, declare resolvers in the desired order
- Use appropriate identity keys for each provider (AD: `sAMAccountName`, Entra ID: `UserPrincipalName`)
### Use the global View for "don't care about source"

The most common pattern for entitlements: check or reference entitlements regardless of which provider returned them:
Expand Down Expand Up @@ -263,23 +398,27 @@

---

## Troubleshooting
## Common Patterns and Troubleshooting

### Resolver not executed
### Resolve Once, Use Everywhere

- Ensure `ContextResolvers` is defined at workflow root.
- Verify correct property name (`ContextResolvers`).
Resolve identity/entitlements once during planning, then reuse in conditions, preconditions, and templates:

### Capability not permitted
```powershell
# In step condition
Condition = @{ Exists = 'Request.Context.Identity.Profile' }

- Only allowlisted read-only capabilities can be used.
- Validation happens during plan build.
# In template
Message = '{{Request.Context.Identity.Profile.DisplayName}} offboarded'
```

### Ambiguous provider
### Guard Destructive Operations

- If multiple providers advertise a capability, specify `With.Provider` explicitly.
Only perform actions if identity exists:

### Context value missing
```powershell
Condition = @{ Exists = 'Request.Context.Identity.Profile' }
```

- Verify required `With` parameters.
- Ensure template placeholders resolve correctly.
Expand Down Expand Up @@ -312,7 +451,13 @@
$plan.Request.Context.Providers.Entra.Default.Identity.Profile
```

### Type conflict in context path
| Problem | Solution |
|---------|----------|
| Resolver not executed | Ensure `ContextResolvers` is at workflow root level |
| Capability not permitted | Only `IdLE.Identity.Read` and `IdLE.Entitlement.List` are allowed |
| Ambiguous provider | Specify `With.Provider` explicitly when multiple providers match |
| Context value missing | Verify `With` parameters and template placeholders resolve correctly |
| Type conflict in context | Cannot overwrite existing context path with incompatible type |

- A resolver cannot overwrite an existing path with incompatible type.
- Pre-existing context keys like `Providers` or `Views` must be hashtables.
Expand Down
15 changes: 14 additions & 1 deletion src/IdLE.Core/Private/Copy-IdleDataObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,23 @@ function Copy-IdleDataObject {
$props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property'))
if ($null -ne $props -and @($props).Count -gt 0) {
$o = [ordered]@{}

foreach ($p in $props) {
$o[$p.Name] = Copy-IdleDataObject -Value $p.Value
}
return [pscustomobject]$o

$result = [pscustomobject]$o

# Preserve PSTypeName(s) from the original object by inserting into TypeNames collection
$typeNames = @($Value.PSObject.TypeNames | Where-Object {
$_ -ne 'System.Management.Automation.PSCustomObject' -and $_ -ne 'System.Object'
})
if ($typeNames.Count -gt 0) {
# Insert the primary type name at position 0 (before PSCustomObject)
$result.PSObject.TypeNames.Insert(0, $typeNames[0])
}

return $result
}

return $Value
Expand Down
17 changes: 14 additions & 3 deletions src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -746,10 +746,16 @@ function Invoke-IdleResolverCapabilityDispatch {
}

$supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $method -ParameterName 'AuthSession'
if ($supportsAuthSession -and $null -ne $AuthSession) {
return $provider.GetIdentity($identityKey, $AuthSession)
$identity = if ($supportsAuthSession -and $null -ne $AuthSession) {
$provider.GetIdentity($identityKey, $AuthSession)
}
else {
$provider.GetIdentity($identityKey)
}
return $provider.GetIdentity($identityKey)

# Return the identity object as-is with nested Attributes.
# Users access attributes via Request.Context.Providers.<ProviderAlias>.<AuthKey>.Identity.Profile.Attributes.<AttributeName>
return $identity
}

default {
Expand Down Expand Up @@ -813,3 +819,8 @@ function Set-IdleContextValue {

$current[$segments[-1]] = $Value
}

# ConvertTo-IdleFlattenedIdentity function removed - attributes are kept nested under Profile.Attributes
# The scoped context model uses: Request.Context.Providers.<ProviderAlias>.<AuthKey>.Identity.Profile.Attributes.<AttributeName>
# NOT flattened to: Request.Context.Providers.<ProviderAlias>.<AuthKey>.Identity.Profile.<AttributeName>

Loading
Loading