diff --git a/.gitignore b/.gitignore index 8e1bd534..4c4c0dfc 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ coverage.* test-results.* *.lcov testresults.xml +testResults.xml +*.trx docs-audit.json # Packages diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 7301c8cc..c630cf1f 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -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 diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 0fdfef4f..e4950efa 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -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 | diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index da8a984e..d2549d85 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -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) | diff --git a/docs/reference/providers/provider-mock.md b/docs/reference/providers/provider-mock.md index 92df852c..10ce5d44 100644 --- a/docs/reference/providers/provider-mock.md +++ b/docs/reference/providers/provider-mock.md @@ -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). diff --git a/docs/use/workflows/context-resolver.md b/docs/use/workflows/context-resolver.md index 3df8ff38..1a3f55eb 100644 --- a/docs/use/workflows/context-resolver.md +++ b/docs/use/workflows/context-resolver.md @@ -109,20 +109,26 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). ```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 = @{ @@ -135,9 +141,9 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). ) 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: @@ -146,10 +152,10 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). } } + # 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}}' } @@ -158,13 +164,18 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). } ``` -### 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 | |---|---|---|---| @@ -175,8 +186,132 @@ Resolved from `Step.With.Provider` + `Step.With.AuthSessionName` (or `Default`). --- -## 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: @@ -263,23 +398,27 @@ This enables auditing and per-source filtering when working with merged views. --- -## 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. @@ -312,7 +451,13 @@ To get the profile from a specific provider, use the scoped source-of-truth path $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. diff --git a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 index 804fa1b3..2336c828 100644 --- a/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 +++ b/src/IdLE.Core/Private/Copy-IdleDataObject.ps1 @@ -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 diff --git a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 index e928608b..388883ee 100644 --- a/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 +++ b/src/IdLE.Core/Private/Invoke-IdleContextResolvers.ps1 @@ -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...Identity.Profile.Attributes. + return $identity } default { @@ -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...Identity.Profile.Attributes. +# NOT flattened to: Request.Context.Providers...Identity.Profile. + diff --git a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 index 3966f3c8..33b8e5c6 100644 --- a/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 +++ b/tests/Core/New-IdlePlan.ContextResolvers.Tests.ps1 @@ -129,6 +129,162 @@ Describe 'New-IdlePlan - ContextResolvers' { $plan.Request.Context.Providers.Identity.Default.Identity.Profile | Should -Not -BeNullOrEmpty $plan.Request.Context.Providers.Identity.Default.Identity.Profile.IdentityKey | Should -Be 'user1' } + + It 'IdLE.Identity.Read resolver flattens Attributes to top-level properties' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ + DisplayName = 'User One' + Department = 'IT' + EmailAddress = 'user1@example.com' + UserPrincipalName = 'user1@example.com' + } + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + # Profile is written to scoped path: Providers...Identity.Profile + $profile = $plan.Request.Context.Providers.Identity.Default.Identity.Profile + + # Core properties should be present + $profile.IdentityKey | Should -Be 'user1' + $profile.Enabled | Should -Be $true + + # Attributes should be nested under Profile.Attributes (not flattened) + $profile.Attributes | Should -Not -BeNullOrEmpty + $profile.Attributes.DisplayName | Should -Be 'User One' + $profile.Attributes.Department | Should -Be 'IT' + $profile.Attributes.EmailAddress | Should -Be 'user1@example.com' + $profile.Attributes.UserPrincipalName | Should -Be 'user1@example.com' + + # PSTypeName should be preserved from the original identity object + $profile.PSObject.TypeNames | Should -Contain 'IdLE.Identity' + } + + It 'IdLE.Identity.Read resolver handles null Attributes gracefully' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = $null + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + # Profile is written to scoped path: Providers...Identity.Profile + $profile = $plan.Request.Context.Providers.Identity.Default.Identity.Profile + + # Core properties should be present + $profile.IdentityKey | Should -Be 'user1' + $profile.Enabled | Should -Be $true + + # Attributes should remain null (not flattened, kept as-is) + $profile.Attributes | Should -BeNullOrEmpty + } + + It 'IdLE.Identity.Read resolver handles empty Attributes hashtable' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{} + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + # Profile is written to scoped path: Providers...Identity.Profile + $profile = $plan.Request.Context.Providers.Identity.Default.Identity.Profile + + # Core properties should be present + $profile.IdentityKey | Should -Be 'user1' + $profile.Enabled | Should -Be $true + + # Attributes should be empty hashtable (not flattened, kept as-is) + $profile.PSObject.Properties.Name | Should -Contain 'Attributes' + if ($null -ne $profile.Attributes) { + $profile.Attributes.Count | Should -Be 0 + } + } + + It 'IdLE.Identity.Read resolver does not overwrite core properties with conflicting attributes' { + $wfPath = Join-Path $script:FixturesPath 'resolver-identity-read.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ + IdentityKey = 'conflicting-value' # This conflicts with core property + Enabled = $false # This also conflicts + DisplayName = 'User One' + } + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + # Profile is written to scoped path: Providers...Identity.Profile + $profile = $plan.Request.Context.Providers.Identity.Default.Identity.Profile + + # Core IdentityKey should NOT be overwritten by attributes (attributes stay nested) + $profile.IdentityKey | Should -Be 'user1' + + # Core Enabled should NOT be overwritten by attributes (attributes stay nested) + $profile.Enabled | Should -Be $true + + # Attributes should remain nested with all keys intact (no flattening) + $profile.Attributes | Should -Not -BeNullOrEmpty + $profile.Attributes.IdentityKey | Should -Be 'conflicting-value' + $profile.Attributes.Enabled | Should -Be $false + $profile.Attributes.DisplayName | Should -Be 'User One' + } } Context 'To is not a supported key (output path is predefined per capability)' { @@ -298,6 +454,65 @@ Describe 'New-IdlePlan - ContextResolvers' { $entitlements.Count | Should -Be 1 $entitlements[0].Id | Should -Be 'tmpl-grp' } + + It 'resolves templates using nested Identity.Profile.Attributes paths' { + # Create a workflow with a step that uses template substitution with Identity.Profile.Attributes + $wfContent = @' +@{ + Name = 'Identity Profile Template Test' + LifecycleEvent = 'Joiner' + ContextResolvers = @( + @{ + Capability = 'IdLE.Identity.Read' + With = @{ + IdentityKey = '{{Request.IdentityKeys.Id}}' + Provider = 'Identity' + } + } + ) + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'User: {{Request.Context.Providers.Identity.Default.Identity.Profile.Attributes.DisplayName}}, Email: {{Request.Context.Providers.Identity.Default.Identity.Profile.Attributes.EmailAddress}}' + Department = '{{Request.Context.Providers.Identity.Default.Identity.Profile.Attributes.Department}}' + } + } + ) +} +'@ + $tempWfPath = Join-Path $TestDrive 'wf-identity-profile-template.psd1' + Set-Content -Path $tempWfPath -Value $wfContent + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ Id = 'user1' } + + $provider = New-IdleMockIdentityProvider -InitialStore @{ + 'user1' = @{ + IdentityKey = 'user1' + Enabled = $true + Attributes = @{ + DisplayName = 'John Doe' + EmailAddress = 'john.doe@example.com' + Department = 'Engineering' + } + Entitlements = @() + } + } + + $providers = @{ + Identity = $provider + StepRegistry = @{ 'IdLE.Step.EmitEvent' = 'Invoke-IdleContextResolverTestNoopStep' } + } + + $plan = New-IdlePlan -WorkflowPath $tempWfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.Steps[0].Status | Should -Be 'Planned' + # Verify templates were resolved using nested Attributes paths + $plan.Steps[0].With.Message | Should -Be 'User: John Doe, Email: john.doe@example.com' + $plan.Steps[0].With.Department | Should -Be 'Engineering' + } } Context 'Auth session threading' { diff --git a/tests/Core/Test-IdleCondition.Tests.ps1 b/tests/Core/Test-IdleCondition.Tests.ps1 index 09acc3bf..6b11eb66 100644 --- a/tests/Core/Test-IdleCondition.Tests.ps1 +++ b/tests/Core/Test-IdleCondition.Tests.ps1 @@ -123,7 +123,7 @@ Describe 'Condition DSL (schema + evaluator)' { It 'accepts Contains operator with Path + Value' { $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Views.Identity.Entitlements' Value = 'CN=Group,OU=Groups,DC=example,DC=com' } } @@ -146,7 +146,7 @@ Describe 'Condition DSL (schema + evaluator)' { It 'rejects Contains with missing Value' { $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Views.Identity.Entitlements' } } @@ -157,7 +157,7 @@ Describe 'Condition DSL (schema + evaluator)' { It 'accepts NotContains operator with Path + Value' { $condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Views.Identity.Entitlements' Value = 'CN=Group,OU=Groups,DC=example,DC=com' } } @@ -169,7 +169,7 @@ Describe 'Condition DSL (schema + evaluator)' { It 'accepts Like operator with Path + Pattern' { $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' Pattern = '* (Contractor)' } } @@ -181,7 +181,7 @@ Describe 'Condition DSL (schema + evaluator)' { It 'rejects Like with missing Pattern' { $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' } } @@ -192,7 +192,7 @@ Describe 'Condition DSL (schema + evaluator)' { It 'accepts NotLike operator with Path + Pattern' { $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements' + Path = 'Request.Context.Views.Identity.Entitlements' Pattern = 'CN=HR-*' } } @@ -376,20 +376,22 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -401,19 +403,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -425,16 +429,18 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Name = 'John Doe' } + } } } } $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Name' + Path = 'Request.Context.Views.Identity.Name' Value = 'John' } } @@ -446,19 +452,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Metadata = @{ Department = 'Engineering' Location = 'Seattle' } } + } } } } $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Metadata' + Path = 'Request.Context.Views.Identity.Metadata' Value = 'Engineering' } } @@ -470,19 +478,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Metadata = @{ Department = 'Engineering' Location = 'Seattle' } } + } } } } $condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Metadata' + Path = 'Request.Context.Views.Identity.Metadata' Value = 'HR' } } @@ -494,19 +504,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Metadata = @{ Department = 'Engineering' Location = 'Seattle' } } + } } } } $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Metadata' + Path = 'Request.Context.Views.Identity.Metadata' Pattern = 'Eng*' } } @@ -518,19 +530,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Metadata = @{ Department = 'Engineering' Location = 'Seattle' } } + } } } } $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Metadata' + Path = 'Request.Context.Views.Identity.Metadata' Pattern = 'HR*' } } @@ -542,19 +556,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -566,19 +582,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ NotContains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=BreakGlass-Users,OU=Groups,DC=example,DC=com' } } @@ -590,18 +608,20 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Profile = [pscustomobject]@{ DisplayName = 'John Doe (Contractor)' } } + } } } } $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' Pattern = '* (Contractor)' } } @@ -613,18 +633,20 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Profile = [pscustomobject]@{ DisplayName = 'John Doe' } } + } } } } $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' Pattern = '* (Contractor)' } } @@ -636,20 +658,22 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -661,19 +685,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -685,18 +711,20 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Profile = [pscustomobject]@{ DisplayName = 'John Doe' } } + } } } } $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' Pattern = '* (Contractor)' } } @@ -708,18 +736,20 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Profile = [pscustomobject]@{ DisplayName = 'John Doe (Contractor)' } } + } } } } $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' Pattern = '* (Contractor)' } } @@ -731,19 +761,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -755,19 +787,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ NotLike = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Pattern = 'CN=HR-*' } } @@ -779,19 +813,21 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Entitlements = @( [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' } ) } + } } } } $condition = @{ Contains = @{ - Path = 'Request.Context.Identity.Entitlements.Id' + Path = 'Request.Context.Views.Identity.Entitlements.Id' Value = 'CN=USERS,OU=Groups,DC=example,DC=com' } } @@ -803,18 +839,20 @@ Describe 'Condition DSL (schema + evaluator)' { $context = [pscustomobject]@{ Request = [pscustomobject]@{ Context = [pscustomobject]@{ - Identity = [pscustomobject]@{ + Views = [pscustomobject]@{ + Identity = [pscustomobject]@{ Profile = [pscustomobject]@{ DisplayName = 'john doe (contractor)' } } + } } } } $condition = @{ Like = @{ - Path = 'Request.Context.Identity.Profile.DisplayName' + Path = 'Request.Context.Views.Identity.Profile.DisplayName' Pattern = '* (CONTRACTOR)' } }