diff --git a/.gitignore b/.gitignore index a1bcdc1c..8e1bd534 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,11 @@ pids *.seed *.pid.lock +# Temp directories +# Temp files +tmp/ +temp/ +*.tmp +*.temp + # End of file \ No newline at end of file diff --git a/docs/extend/extensibility.md b/docs/extend/extensibility.md index 21da75eb..1a95cec6 100644 --- a/docs/extend/extensibility.md +++ b/docs/extend/extensibility.md @@ -141,4 +141,4 @@ The following are **not contracts** and may change in minor/patch versions: **Lifecycle request contract**: - Required fields: `LifecycleEvent`, `CorrelationId` -- Optional fields: `Actor`, `IdentityKeys`, `DesiredState`, `Changes` +- Optional fields: `Actor`, `IdentityKeys`, `Intent`, `Context`, `Changes` diff --git a/docs/reference/cmdlets/New-IdleRequest.md b/docs/reference/cmdlets/New-IdleRequest.md index f4aa9015..69e111af 100644 --- a/docs/reference/cmdlets/New-IdleRequest.md +++ b/docs/reference/cmdlets/New-IdleRequest.md @@ -14,7 +14,7 @@ Creates a lifecycle request object. ``` New-IdleRequest [-LifecycleEvent] <String> [[-CorrelationId] <String>] [[-Actor] <String>] - [[-IdentityKeys] <Hashtable>] [[-DesiredState] <Hashtable>] [[-Changes] <Hashtable>] + [[-IdentityKeys] <Hashtable>] [[-Intent] <Hashtable>] [[-Context] <Hashtable>] [[-Changes] <Hashtable>] [-ProgressAction <ActionPreference>] [<CommonParameters>] ``` @@ -30,9 +30,16 @@ Changes is optional and stays $null when omitted. ### EXAMPLE 1 ``` +# Minimal Joiner request - CorrelationId is auto-generated, Intent/Context default to empty New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } ``` +### EXAMPLE 2 +``` +# Joiner request with caller-provided action inputs (Intent) and read-only associated context (Context) +New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } -Intent @{ Department = 'Engineering'; Title = 'Engineer' } -Context @{ Identity = @{ ObjectId = 'abc-123' } } +``` + ## PARAMETERS ### -LifecycleEvent @@ -99,8 +106,9 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -DesiredState -A hashtable describing the desired state (attributes, entitlements, etc.). +### -Intent +A hashtable containing the caller-provided action inputs for the workflow (attributes, +entitlements, operator flags, etc.). ```yaml Type: Hashtable @@ -114,6 +122,24 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Context +A hashtable containing read-only associated context provided by the host or resolvers +(e.g. +identity snapshots, device hints). +Must not be treated as mutable state within IdLE. + +```yaml +Type: Hashtable +Parameter Sets: (All) +Aliases: + +Required: False +Position: 6 +Default value: @{} +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Changes Optional hashtable describing changes (typically used for Mover lifecycle events). @@ -123,7 +149,7 @@ Parameter Sets: (All) Aliases: Required: False -Position: 6 +Position: 7 Default value: None Accept pipeline input: False Accept wildcard characters: False diff --git a/docs/reference/providers/provider-mock.md b/docs/reference/providers/provider-mock.md index 576e861c..149e5b01 100644 --- a/docs/reference/providers/provider-mock.md +++ b/docs/reference/providers/provider-mock.md @@ -16,7 +16,7 @@ import CodeBlock from '@theme/CodeBlock'; Use the Mock provider when you want to: - validate **workflow logic**, conditions, and error handling -- validate **template placeholders** (e.g. `{{Request.Input...}}`) without external dependencies +- validate **template placeholders** (e.g. `{{Request.Intent...}}`) without external dependencies - build demos or CI checks that should never modify production systems Non-goals: diff --git a/docs/use/quickstart.md b/docs/use/quickstart.md index 2db77173..df3cb642 100644 --- a/docs/use/quickstart.md +++ b/docs/use/quickstart.md @@ -77,8 +77,8 @@ $workflowContent = @' Provider = 'Identity' IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - GivenName = '{{Request.DesiredState.GivenName}}' - Surname = '{{Request.DesiredState.Surname}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' } } } @@ -105,7 +105,7 @@ A request represents business intent (Joiner/Mover/Leaver) plus input data. ```powershell $request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = '12345' -} -DesiredState @{ +} -Intent @{ GivenName = 'Max' Surname = 'Power' } @@ -128,7 +128,7 @@ $providers = @{ ## 5) Build the plan (validation + template resolution) Plan building is a **fail-fast** step. IdLE validates the workflow and resolves templates like -`{{Request.DesiredState.GivenName}}`. +`{{Request.Intent.GivenName}}`. ```powershell $plan = New-IdlePlan -WorkflowPath $workflowPath -Request $request -Providers $providers diff --git a/docs/use/walkthrough/01-workflow-definition.md b/docs/use/walkthrough/01-workflow-definition.md index ac18c365..7660bd73 100644 --- a/docs/use/walkthrough/01-workflow-definition.md +++ b/docs/use/walkthrough/01-workflow-definition.md @@ -52,8 +52,8 @@ Create a file `joiner.psd1` with this content: Provider = 'Identity' IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - GivenName = '{{Request.DesiredState.GivenName}}' - Surname = '{{Request.DesiredState.Surname}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' } } } @@ -74,7 +74,7 @@ Workflows are treated as **untrusted input** and must remain **data-only**. - `Steps` is an ordered list. - Each step references a **StepType** by name (`Type`). - Step configuration lives under `With`. -- Template expressions like `{{Request.DesiredState.GivenName}}` are resolved when building the plan. +- Template expressions like `{{Request.Intent.GivenName}}` are resolved when building the plan. --- diff --git a/docs/use/walkthrough/02-request-creation.md b/docs/use/walkthrough/02-request-creation.md index a73349a9..ada1de62 100644 --- a/docs/use/walkthrough/02-request-creation.md +++ b/docs/use/walkthrough/02-request-creation.md @@ -20,7 +20,7 @@ Create a minimal request that matches the workflow from [Walkthrough 1](01-workf - A request object that contains: - `LifecycleEvent` - `IdentityKeys.EmployeeId` - - `DesiredState.GivenName` and `DesiredState.Surname` + - `Intent.GivenName` and `Intent.Surname` --- @@ -31,7 +31,7 @@ In PowerShell, create the request like this: ```powershell $request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = '12345' -} -DesiredState @{ +} -Intent @{ GivenName = 'Max' Surname = 'Power' } @@ -40,8 +40,8 @@ $request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ This request provides the values referenced in the workflow templates: - `{{Request.IdentityKeys.EmployeeId}}` -- `{{Request.DesiredState.GivenName}}` -- `{{Request.DesiredState.Surname}}` +- `{{Request.Intent.GivenName}}` +- `{{Request.Intent.Surname}}` --- @@ -56,8 +56,8 @@ Identity keys are typically: - unique - provided by the upstream system (HR, IAM, ticket) -### DesiredState -Desired state contains the data you want IdLE to enforce (attributes, entitlements, mailbox settings, …). +### Intent +Intent contains the caller-provided action inputs (attributes, entitlements, mailbox settings, …) that the workflow should act on. For this walkthrough we keep it minimal and only set two attributes. diff --git a/docs/use/walkthrough/03-plan-creation.md b/docs/use/walkthrough/03-plan-creation.md index 8953dd15..622dba1c 100644 --- a/docs/use/walkthrough/03-plan-creation.md +++ b/docs/use/walkthrough/03-plan-creation.md @@ -19,7 +19,7 @@ Build a plan from your workflow and request, while supplying providers (recommen ## You will have - A plan object that is safe to review and execute -- Templates resolved (for example `{{Request.DesiredState.GivenName}}`) +- Templates resolved (for example `{{Request.Intent.GivenName}}`) - Validation errors surfaced early (before execution) --- @@ -59,7 +59,7 @@ During plan build IdLE typically: - validates the workflow structure and step types - validates that referenced providers exist (when supplied) - checks required capabilities (provider/step contracts) -- resolves template expressions (for example `{{Request.DesiredState.GivenName}}`) +- resolves template expressions (for example `{{Request.Intent.GivenName}}`) - produces a deterministic execution plan :::info diff --git a/docs/use/walkthrough/05-providers-authentication.md b/docs/use/walkthrough/05-providers-authentication.md index ba0d1420..7bf67020 100644 --- a/docs/use/walkthrough/05-providers-authentication.md +++ b/docs/use/walkthrough/05-providers-authentication.md @@ -51,8 +51,8 @@ Example workflow usage: Provider = 'Identity' IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - GivenName = '{{Request.DesiredState.GivenName}}' - Surname = '{{Request.DesiredState.Surname}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' } } } @@ -108,7 +108,7 @@ Example (step requests a named session): IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - Department = '{{Request.DesiredState.Department}}' + Department = '{{Request.Intent.Department}}' } } } diff --git a/docs/use/workflows.md b/docs/use/workflows.md index c32f96c0..72a44e8b 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -74,8 +74,8 @@ request during plan build (`New-IdlePlan`). Multiple placeholders may appear in ```powershell IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' -DisplayName = '{{Request.DesiredState.GivenName}}' -Message = 'User {{Request.DesiredState.DisplayName}} is joining.' +DisplayName = '{{Request.Intent.GivenName}}' +Message = 'User {{Request.Intent.DisplayName}} is joining.' ``` ### Allowed roots @@ -84,13 +84,13 @@ For security, only these path roots are permitted: | Root | Description | | ---- | ----------- | -| `Request.DesiredState.*` | Intended target state of the identity | +| `Request.Intent.*` | Caller-provided action inputs | +| `Request.Context.*` | Read-only associated context (host/resolver-provided) | | `Request.IdentityKeys.*` | Identifiers of the target identity | | `Request.Changes.*` | Explicit deltas (Mover events) | | `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | | `Request.CorrelationId` | Stable correlation identifier | | `Request.Actor` | Originator of the request | -| `Request.Input.*` | Alias for `Request.DesiredState.*` when no `Input` property exists | ### Pure vs. mixed placeholders @@ -98,13 +98,13 @@ A value containing **only** a single placeholder preserves the resolved type (bo ```powershell # Resolves to the actual [bool] value, not the string "True" -Enabled = '{{Request.DesiredState.IsEnabled}}' +Enabled = '{{Request.Intent.IsEnabled}}' ``` A value with surrounding text always produces a **string**: ```powershell -Message = 'Account for {{Request.DesiredState.DisplayName}} created.' +Message = 'Account for {{Request.Intent.DisplayName}} created.' ``` ### Backslash and special characters @@ -140,7 +140,7 @@ Summary of backslash behaviour: | `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, valid template resolved | | `Literal \{{ braces here` | `Literal {{ braces here` — escape applied | | `\{{Request.InvalidRoot}}` | `{{Request.InvalidRoot}}` — invalid root, escape applied | -| `Literal \{{ and {{Request.Input.Name}}` | `Literal {{ and TestName` — escape + template | +| `Literal \{{ and {{Request.Intent.Name}}` | `Literal {{ and TestName` — escape + template | ### Validation diff --git a/examples/Invoke-LeaverWithManagerOOF.ps1 b/examples/Invoke-LeaverWithManagerOOF.ps1 index bf23be82..d3b8fd34 100644 --- a/examples/Invoke-LeaverWithManagerOOF.ps1 +++ b/examples/Invoke-LeaverWithManagerOOF.ps1 @@ -10,7 +10,7 @@ template variables in Out of Office messages. Key concepts: - Manager lookup is performed HOST-SIDE, not inside workflow steps - Request enrichment happens before calling New-IdlePlan -- Templates like {{Request.DesiredState.Manager.DisplayName}} are resolved during planning +- Templates like {{Request.Intent.Manager.DisplayName}} are resolved during planning .NOTES This is an example only. Adapt authentication, provider setup, and directory queries @@ -112,18 +112,20 @@ switch ($DirectorySource) { } } -# 2. Build lifecycle request with enriched DesiredState +# 2. Build lifecycle request with caller-provided Intent Write-Host "==> Building lifecycle request..." -ForegroundColor Cyan -$desiredState = @{} +$enrichedIntent = @{ + UserPrincipalName = $UserPrincipalName +} if ($managerInfo) { - $desiredState['Manager'] = $managerInfo + $enrichedIntent['Manager'] = $managerInfo } else { # Fallback: use generic support contact Write-Warning "No manager found; using generic support contact in OOF message." - $desiredState['Manager'] = @{ + $enrichedIntent['Manager'] = @{ DisplayName = 'IT Support' Mail = 'support@contoso.com' } @@ -132,10 +134,7 @@ else { $request = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` - -Input @{ - UserPrincipalName = $UserPrincipalName - } ` - -DesiredState $desiredState + -Intent $enrichedIntent Write-Host " Request CorrelationId: $($request.CorrelationId)" -ForegroundColor Gray diff --git a/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 b/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 index c19cd4d0..39c55d80 100644 --- a/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 +++ b/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 @@ -9,14 +9,14 @@ Type = 'IdLE.Step.CreateIdentity' With = @{ Provider = 'Directory' - AuthSessionName = '{{Request.Input.Auth.Directory}}' + AuthSessionName = '{{Request.Intent.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - GivenName = '{{Request.Input.GivenName}}' - Surname = '{{Request.Input.Surname}}' - Department = '{{Request.Input.Department}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' + Department = '{{Request.Intent.Department}}' } } } @@ -48,12 +48,12 @@ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Entitlement = @{ Kind = 'Group' - Id = '{{Request.Input.AllEmployeesGroupId}}' - DisplayName = '{{Request.Input.AllEmployeesGroupName}}' + Id = '{{Request.Intent.AllEmployeesGroupId}}' + DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' } State = 'Present' } diff --git a/examples/workflows/templates/ad-joiner.psd1 b/examples/workflows/templates/ad-joiner.psd1 index 74f4dd66..5b5ba603 100644 --- a/examples/workflows/templates/ad-joiner.psd1 +++ b/examples/workflows/templates/ad-joiner.psd1 @@ -14,13 +14,13 @@ # Provider-specific: identify the target identity # The exact key names depend on provider contracts; keep it consistent with your provider docs. - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: initial attributes that are commonly required Attributes = @{ - GivenName = '{{Request.Input.GivenName}}' - Surname = '{{Request.Input.Surname}}' - DisplayName = '{{Request.Input.DisplayName}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' + DisplayName = '{{Request.Intent.DisplayName}}' } } } @@ -30,16 +30,16 @@ Name = 'Ensure core attributes' With = @{ AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - Mail = '{{Request.Input.Mail}}' - Department = '{{Request.Input.Department}}' - Title = '{{Request.Input.Title}}' - Company = '{{Request.Input.Company}}' - Office = '{{Request.Input.Office}}' - Manager = '{{Request.Input.ManagerSamAccountName}}' - TelephoneNumber = '{{Request.Input.Phone}}' + Mail = '{{Request.Intent.Mail}}' + Department = '{{Request.Intent.Department}}' + Title = '{{Request.Intent.Title}}' + Company = '{{Request.Intent.Company}}' + Office = '{{Request.Intent.Office}}' + Manager = '{{Request.Intent.ManagerSamAccountName}}' + TelephoneNumber = '{{Request.Intent.Phone}}' } } } @@ -49,11 +49,11 @@ Name = 'Ensure baseline group membership (1)' With = @{ AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.BaselineGroups.0}}'; - DisplayName = '{{Request.Input.BaselineGroups.0}}' + Id = '{{Request.Intent.BaselineGroups.0}}'; + DisplayName = '{{Request.Intent.BaselineGroups.0}}' } State = 'Present' } @@ -63,11 +63,11 @@ Name = 'Ensure baseline group membership (2)' With = @{ AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.BaselineGroups.1}}'; - DisplayName = '{{Request.Input.BaselineGroups.1}}' + Id = '{{Request.Intent.BaselineGroups.1}}'; + DisplayName = '{{Request.Intent.BaselineGroups.1}}' } State = 'Present' } @@ -83,14 +83,14 @@ Name = 'Mover: update org attributes (optional)' With = @{ # Guard by convention: only run when request indicates mover - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - Department = '{{Request.Input.NewDepartment}}' - Title = '{{Request.Input.NewTitle}}' - Office = '{{Request.Input.NewOffice}}' - Manager = '{{Request.Input.NewManagerSamAccountName}}' + Department = '{{Request.Intent.NewDepartment}}' + Title = '{{Request.Intent.NewTitle}}' + Office = '{{Request.Intent.NewOffice}}' + Manager = '{{Request.Intent.NewManagerSamAccountName}}' Description = 'Moved on {{Request.Execution.Timestamp}}' } } @@ -100,12 +100,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, baseline 1)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.BaselineGroups.0}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.BaselineGroups.0}}' } State = 'Present' } } @@ -113,12 +113,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, baseline 2)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.BaselineGroups.1}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.BaselineGroups.1}}' } State = 'Present' } } @@ -126,12 +126,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, department 1)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.DepartmentGroups.0}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.DepartmentGroups.0}}' } State = 'Present' } } @@ -139,12 +139,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, department 2)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.DepartmentGroups.1}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.DepartmentGroups.1}}' } State = 'Present' } } diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index 7bf4e6da..3924d76d 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -9,8 +9,8 @@ Name = 'Disable identity' With = @{ AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' - Reason = '{{Request.Input.LeaverReason}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' + Reason = '{{Request.Intent.LeaverReason}}' } } @@ -19,9 +19,9 @@ Name = 'Stamp offboarding attributes' With = @{ AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - Description = 'Leaver (CorrelationId: {{Request.CorrelationId}}) - {{Request.Input.LeaverReason}}' + Description = 'Leaver (CorrelationId: {{Request.CorrelationId}}) - {{Request.Intent.LeaverReason}}' } } } @@ -33,14 +33,14 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Remove managed group memberships (optional, item 1)' With = @{ - Condition = @{ Equals = @{ Path = 'Request.Input.RemoveGroups'; Value = $true } } + Condition = @{ Equals = @{ Path = 'Request.Intent.RemoveGroups'; Value = $true } } AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Only remove what you explicitly manage via IdLE. Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.ManagedGroupsToRemove.0}}' + Id = '{{Request.Intent.ManagedGroupsToRemove.0}}' } State = 'Absent' } @@ -49,14 +49,14 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Remove managed group memberships (optional, item 2)' With = @{ - Condition = @{ Equals = @{ Path = 'Request.Input.RemoveGroups'; Value = $true } } + Condition = @{ Equals = @{ Path = 'Request.Intent.RemoveGroups'; Value = $true } } AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Only remove what you explicitly manage via IdLE. Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.ManagedGroupsToRemove.1}}' + Id = '{{Request.Intent.ManagedGroupsToRemove.1}}' } State = 'Absent' } @@ -70,10 +70,10 @@ Type = 'IdLE.Step.MoveIdentity' Name = 'Move to Disabled OU (optional)' With = @{ - Condition = @{ Equals = @{ Path = 'Request.Input.MoveToDisabledOu'; Value = $true } } + Condition = @{ Equals = @{ Path = 'Request.Intent.MoveToDisabledOu'; Value = $true } } AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' - TargetContainer = '{{Request.Input.DisabledOuPath}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' + TargetContainer = '{{Request.Intent.DisabledOuPath}}' } } ) diff --git a/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 b/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 index 5f3af1cb..a8470938 100644 --- a/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 +++ b/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 @@ -17,7 +17,7 @@ } # Delta or Initial - PolicyType = '{{Request.Input.PolicyType}}' + PolicyType = '{{Request.Intent.PolicyType}}' # Optional wait/polling behavior (step-specific) Wait = $true @@ -30,7 +30,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Entra Connect sync cycle ({{Request.Input.PolicyType}}) triggered successfully.' + Message = 'Entra Connect sync cycle ({{Request.Intent.PolicyType}}) triggered successfully.' } } ) diff --git a/examples/workflows/templates/entraid-exo-leaver.psd1 b/examples/workflows/templates/entraid-exo-leaver.psd1 index 23cee81e..c70221de 100644 --- a/examples/workflows/templates/entraid-exo-leaver.psd1 +++ b/examples/workflows/templates/entraid-exo-leaver.psd1 @@ -8,7 +8,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } } } @{ @@ -16,7 +16,7 @@ Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } MailboxType = 'Shared' } } @@ -25,7 +25,7 @@ Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' @@ -41,7 +41,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } Desired = @() } } @@ -52,7 +52,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } Attributes = @{ Manager = $null } @@ -65,7 +65,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } } } @{ diff --git a/examples/workflows/templates/entraid-joiner.psd1 b/examples/workflows/templates/entraid-joiner.psd1 index f0a54174..3755ce94 100644 --- a/examples/workflows/templates/entraid-joiner.psd1 +++ b/examples/workflows/templates/entraid-joiner.psd1 @@ -12,26 +12,26 @@ AuthSessionOptions = @{ Role = 'Admin' } # Using UPN keeps it human-friendly in templates. - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Attributes = @{ - UserPrincipalName = '{{Request.Input.UserPrincipalName}}' - DisplayName = '{{Request.Input.DisplayName}}' - GivenName = '{{Request.Input.GivenName}}' - Surname = '{{Request.Input.Surname}}' - Mail = '{{Request.Input.Mail}}' + UserPrincipalName = '{{Request.Intent.UserPrincipalName}}' + DisplayName = '{{Request.Intent.DisplayName}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' + Mail = '{{Request.Intent.Mail}}' # Optional org attributes (safe when empty) - Department = '{{Request.Input.Department}}' - JobTitle = '{{Request.Input.JobTitle}}' - OfficeLocation = '{{Request.Input.OfficeLocation}}' - CompanyName = '{{Request.Input.CompanyName}}' + Department = '{{Request.Intent.Department}}' + JobTitle = '{{Request.Intent.JobTitle}}' + OfficeLocation = '{{Request.Intent.OfficeLocation}}' + CompanyName = '{{Request.Intent.CompanyName}}' # Password profile is typically relevant for "new user" scenarios. - # Your host can generate and provide a temporary password in Request.Input. + # Your host can generate and provide a temporary password in Request.Intent. PasswordProfile = @{ forceChangePasswordNextSignIn = $true - password = '{{Request.Input.TemporaryPassword}}' + password = '{{Request.Intent.TemporaryPassword}}' } } } @@ -45,19 +45,19 @@ AuthSessionOptions = @{ Role = 'Admin' } # Using UPN keeps it human-friendly in templates. - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' # Baseline groups should be explicit and driven by request input (no hardcoding). Desired = @( @{ Kind = 'Group' - Id = '{{Request.Input.AllEmployeesGroupId}}' - DisplayName = '{{Request.Input.AllEmployeesGroupName}}' + Id = '{{Request.Intent.AllEmployeesGroupId}}' + DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' } @{ Kind = 'Group' - Id = '{{Request.Input.DepartmentGroupId}}' - DisplayName = '{{Request.Input.DepartmentGroupName}}' + Id = '{{Request.Intent.DepartmentGroupId}}' + DisplayName = '{{Request.Intent.DepartmentGroupName}}' } ) } @@ -69,13 +69,13 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } # ---------------------------- # Mover patterns (optional) - # Enable by setting: Request.Input.IsMover = $true + # Enable by setting: Request.Intent.IsMover = $true # ---------------------------- @{ @@ -86,7 +86,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.IsMover' + Path = 'Request.Intent.IsMover' Value = $true } } @@ -95,13 +95,13 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Attributes = @{ - Department = '{{Request.Input.NewDepartment}}' - JobTitle = '{{Request.Input.NewJobTitle}}' - OfficeLocation = '{{Request.Input.NewOfficeLocation}}' - Manager = '{{Request.Input.NewManagerObjectId}}' + Department = '{{Request.Intent.NewDepartment}}' + JobTitle = '{{Request.Intent.NewJobTitle}}' + OfficeLocation = '{{Request.Intent.NewOfficeLocation}}' + Manager = '{{Request.Intent.NewManagerObjectId}}' } } } @@ -113,7 +113,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.IsMover' + Path = 'Request.Intent.IsMover' Value = $true } } @@ -122,19 +122,19 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' # Optional: add department/project groups as part of a move. Desired = @( @{ Kind = 'Group' - Id = '{{Request.Input.DepartmentGroupId}}' - DisplayName = '{{Request.Input.DepartmentGroupName}}' + Id = '{{Request.Intent.DepartmentGroupId}}' + DisplayName = '{{Request.Intent.DepartmentGroupName}}' } @{ Kind = 'Group' - Id = '{{Request.Input.ProjectGroupId}}' - DisplayName = '{{Request.Input.ProjectGroupName}}' + Id = '{{Request.Intent.ProjectGroupId}}' + DisplayName = '{{Request.Intent.ProjectGroupName}}' } ) } @@ -144,7 +144,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'EntraID user {{Request.Input.UserPrincipalName}} created/updated successfully.' + Message = 'EntraID user {{Request.Intent.UserPrincipalName}} created/updated successfully.' } } ) diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 926929bd..4c8cfdd8 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -12,7 +12,7 @@ AuthSessionOptions = @{ Role = 'Admin' } # Prefer ObjectId for leaver (stable), but you may also use UPN if your provider supports it. - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -22,7 +22,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -32,9 +32,9 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Attributes = @{ - DisplayName = '{{Request.Input.DisplayName}} (LEAVER)' + DisplayName = '{{Request.Intent.DisplayName}} (LEAVER)' Manager = $null } } @@ -49,7 +49,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.RevokeAllGroupMemberships' + Path = 'Request.Intent.RevokeAllGroupMemberships' Value = $true } } @@ -58,7 +58,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Entitlement = @{ Kind = 'Group'; Id = '*' @@ -75,7 +75,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.DeleteAfterDisable' + Path = 'Request.Intent.DeleteAfterDisable' Value = $true } } @@ -84,7 +84,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Tier0' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -92,7 +92,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'EntraID user {{Request.Input.UserPrincipalName}} offboarding completed.' + Message = 'EntraID user {{Request.Intent.UserPrincipalName}} offboarding completed.' } } ) diff --git a/examples/workflows/templates/exo-joiner.psd1 b/examples/workflows/templates/exo-joiner.psd1 index 80006e73..6badfa0d 100644 --- a/examples/workflows/templates/exo-joiner.psd1 +++ b/examples/workflows/templates/exo-joiner.psd1 @@ -10,7 +10,7 @@ Description = 'Reads mailbox details (useful for auditing and troubleshooting).' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -20,7 +20,7 @@ Description = 'Ensures the mailbox is a regular user mailbox.' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' # Allowed values: User | Shared | Room | Equipment MailboxType = 'User' } @@ -32,7 +32,7 @@ Description = 'Ensures Out of Office is disabled for a new joiner mailbox.' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ # Allowed values: Disabled | Enabled | Scheduled Mode = 'Disabled' diff --git a/examples/workflows/templates/exo-leaver.psd1 b/examples/workflows/templates/exo-leaver.psd1 index 0dc55402..3e23de51 100644 --- a/examples/workflows/templates/exo-leaver.psd1 +++ b/examples/workflows/templates/exo-leaver.psd1 @@ -9,7 +9,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @{ @@ -17,7 +17,7 @@ Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' MailboxType = 'Shared' } } @@ -26,7 +26,7 @@ Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ Mode = 'Enabled' MessageFormat = 'Html' @@ -35,14 +35,14 @@
This mailbox is no longer monitored.
For urgent matters, please contact:
This mailbox is no longer monitored.
-Please contact our Service Desk at {{Request.Input.ServiceDesk.Mail}}.
+Please contact our Service Desk at {{Request.Intent.ServiceDesk.Mail}}.
'@ ExternalAudience = 'All' @@ -53,7 +53,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Mailbox offboarding completed for {{Request.Input.UserPrincipalName}}.' + Message = 'Mailbox offboarding completed for {{Request.Intent.UserPrincipalName}}.' } } ) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index 1876070f..7226a225 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -80,16 +80,18 @@ function ConvertTo-IdlePlanExportObject { $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') if ($null -eq $requestInput) { - # IdLE lifecycle requests store business intent as IdentityKeys/DesiredState/Changes. + # IdLE lifecycle requests store business intent as IdentityKeys/Intent/Context/Changes. # When present, export these as the canonical request.input payload. $identityKeys = Get-FirstPropertyValue -Object $request -Names @('IdentityKeys', 'IdentityKey', 'Keys') - $desiredState = Get-FirstPropertyValue -Object $request -Names @('DesiredState', 'TargetState') + $intent = Get-FirstPropertyValue -Object $request -Names @('Intent', 'TargetState') + $context = Get-FirstPropertyValue -Object $request -Names @('Context') $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') - if ($null -ne $identityKeys -or $null -ne $desiredState -or $null -ne $changes) { + if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $context -or $null -ne $changes) { $requestInput = New-OrderedMap $requestInput.identityKeys = $identityKeys - $requestInput.desiredState = $desiredState + $requestInput.intent = $intent + $requestInput.context = $context $requestInput.changes = $changes } } diff --git a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 index ecc34bc4..6c4359e8 100644 --- a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 +++ b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 @@ -10,7 +10,7 @@ function Get-IdleValueByPath { [string] $Path ) - # Supports dotted property paths, e.g. "DesiredState.Department" + # Supports dotted property paths, e.g. "Intent.Department" $current = $Object foreach ($segment in ($Path -split '\.')) { if ($null -eq $current) { return $null } diff --git a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 index 0bdb65cc..514f670e 100644 --- a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 +++ b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 @@ -1,11 +1,15 @@ # Domain model: LifecycleRequest # Actor is intentionally optional in V1 (see architecture). -# Changes is optional and stays $null if not provided (intent-only requests typically only provide DesiredState). +# Changes is optional and stays $null if not provided. +# +# Intent - canonical caller-provided input block. +# Context - read-only associated context provided by the host or resolvers. class IdleLifecycleRequest { [string] $LifecycleEvent [hashtable] $IdentityKeys - [hashtable] $DesiredState + [hashtable] $Intent + [hashtable] $Context [hashtable] $Changes [string] $CorrelationId [string] $Actor @@ -13,14 +17,16 @@ class IdleLifecycleRequest { IdleLifecycleRequest( [string] $lifecycleEvent, [hashtable] $identityKeys, - [hashtable] $desiredState, + [hashtable] $intent, + [hashtable] $context, [hashtable] $changes, [string] $correlationId, [string] $actor ) { $this.LifecycleEvent = $lifecycleEvent $this.IdentityKeys = $identityKeys - $this.DesiredState = $desiredState + $this.Intent = $intent + $this.Context = $context $this.Changes = $changes $this.CorrelationId = $correlationId $this.Actor = $actor @@ -37,8 +43,12 @@ class IdleLifecycleRequest { $this.IdentityKeys = @{} } - if ($null -eq $this.DesiredState) { - $this.DesiredState = @{} + if ($null -eq $this.Intent) { + $this.Intent = @{} + } + + if ($null -eq $this.Context) { + $this.Context = @{} } # Changes stays $null if not provided. If provided, it must be a hashtable. diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 92ed6540..a5e8c1aa 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -13,8 +13,8 @@ function Resolve-IdleTemplateString { - Multiple placeholders are supported in one string Allowed roots (security boundary): - - Request.Input.* (aliased to Request.DesiredState.* if Input does not exist) - - Request.DesiredState.* + - Request.Intent.* (canonical caller-provided action inputs) + - Request.Context.* (read-only associated context) - Request.IdentityKeys.* - Request.Changes.* - Request.LifecycleEvent @@ -72,7 +72,7 @@ function Resolve-IdleTemplateString { # Define validation constants used in multiple paths $pathValidationPattern = '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$' - $allowedRoots = @('Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') + $allowedRoots = @('Request.Intent', 'Request.Context', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') # Helper function to validate path pattern $validatePath = { @@ -107,28 +107,9 @@ function Resolve-IdleTemplateString { $resolvePath = { param([string]$Path) - # Handle Request.Input.* alias to Request.DesiredState.* - $targetPath = $Path - $hasInputProperty = $false - if ($Request.PSObject.Properties['Input']) { - $hasInputProperty = $true - } - - if ($Path.StartsWith('Request.Input.')) { - if (-not $hasInputProperty) { - # Alias to DesiredState - $targetPath = $Path -replace '^Request\.Input\.', 'Request.DesiredState.' - } - } - elseif ($Path -eq 'Request.Input') { - if (-not $hasInputProperty) { - $targetPath = 'Request.DesiredState' - } - } - # Resolve the value (shared path resolver handles hashtables and objects) $contextWrapper = [pscustomobject]@{ Request = $Request } - $resolvedValue = Get-IdleValueByPath -Object $contextWrapper -Path $targetPath + $resolvedValue = Get-IdleValueByPath -Object $contextWrapper -Path $Path # Fail fast on null/missing values if ($null -eq $resolvedValue) { @@ -207,7 +188,7 @@ function Resolve-IdleTemplateString { # like \{{Request.Foo}} (invalid root) or \{{Request..Name}} (double dot) are still escaped to # literal {{, rather than flowing into template parsing and failing with path/root errors. $litOpenPlaceholder = [string][char]0xE001 - $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Input|DesiredState|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' + $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Intent|Context|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' $normalizedValue = if ($stringValue -match '\\{{') { [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) } else { diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 12268cfb..69be1fa5 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -54,6 +54,14 @@ function New-IdlePlanObject { throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') } + # Reject requests that contain Request.Identity — identity snapshots belong under Request.Context.Identity. + if ($reqProps -contains 'Identity') { + throw [System.ArgumentException]::new( + "Request object must not contain property 'Identity'. Identity-related data belongs under 'Context.Identity' (see Request.Context).", + 'Request' + ) + } + # Create a data-only snapshot of the incoming request for deterministic exports. $requestSnapshot = [pscustomobject]@{ PSTypeName = 'IdLE.LifecycleRequestSnapshot' @@ -61,7 +69,8 @@ function New-IdlePlanObject { CorrelationId = ConvertTo-NullIfEmptyString -Value ([string]$Request.CorrelationId) Actor = if ($reqProps -contains 'Actor') { ConvertTo-NullIfEmptyString -Value ([string]$Request.Actor) } else { $null } IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } - DesiredState = if ($reqProps -contains 'DesiredState') { Copy-IdleDataObject -Value $Request.DesiredState } else { $null } + Intent = if ($reqProps -contains 'Intent') { Copy-IdleDataObject -Value $Request.Intent } else { $null } + Context = if ($reqProps -contains 'Context') { Copy-IdleDataObject -Value $Request.Context } else { $null } Changes = if ($reqProps -contains 'Changes') { Copy-IdleDataObject -Value $Request.Changes } else { $null } } diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index cc28c12a..aa948d07 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -8,7 +8,7 @@ function New-IdleRequestObject { (e.g. Joiner/Mover/Leaver). This is the core factory function used by the IdLE module wrapper. The function validates that no ScriptBlocks are present in the input data (IdentityKeys, - DesiredState, Changes) to enforce the data-only configuration principle. Input hashtables + Intent, Context, Changes) to enforce the data-only configuration principle. Input hashtables are cloned to prevent external mutation after object creation. CorrelationId is preserved if provided; otherwise, the IdleLifecycleRequest class generates @@ -30,9 +30,15 @@ function New-IdleRequestObject { A hashtable of system-neutral identity keys (e.g. @{ EmployeeId = '12345'; UPN = 'user@contoso.com' }). Defaults to an empty hashtable if not provided. Must not contain ScriptBlocks. - .PARAMETER DesiredState - A hashtable describing the desired state for the identity (attributes, entitlements, etc.). - Defaults to an empty hashtable if not provided. Must not contain ScriptBlocks. + .PARAMETER Intent + A hashtable containing the caller-provided action inputs for the workflow (attributes, + entitlements, operator flags, etc.). Defaults to an empty hashtable if not provided. + Must not contain ScriptBlocks. + + .PARAMETER Context + A hashtable containing read-only associated context provided by the host or resolvers + (e.g. identity snapshots, device hints). Defaults to an empty hashtable if not provided. + Must not contain ScriptBlocks. Must not be treated as mutable state within IdLE. .PARAMETER Changes Optional hashtable describing changes (typically used for Mover lifecycle events to indicate @@ -41,12 +47,12 @@ function New-IdleRequestObject { .EXAMPLE $request = New-IdleRequestObject -LifecycleEvent 'Joiner' - Creates a minimal Joiner request with auto-generated CorrelationId and empty IdentityKeys/DesiredState. + Creates a minimal Joiner request with auto-generated CorrelationId and empty Intent/Context. .EXAMPLE - $request = New-IdleRequestObject -LifecycleEvent 'Joiner' -CorrelationId (New-Guid).Guid -IdentityKeys @{ EmployeeId = '12345' } -DesiredState @{ Department = 'Engineering'; MailNickname = 'jdoe'; Title = 'Engineer' } + $request = New-IdleRequestObject -LifecycleEvent 'Joiner' -CorrelationId (New-Guid).Guid -IdentityKeys @{ EmployeeId = '12345' } -Intent @{ Department = 'Engineering'; MailNickname = 'jdoe'; Title = 'Engineer' } - Creates a Joiner request with specific identity keys and desired state attributes for a typical onboarding workflow. + Creates a Joiner request with specific identity keys and intent attributes for a typical onboarding workflow. .EXAMPLE $request = New-IdleRequestObject -LifecycleEvent 'Mover' -IdentityKeys @{ UPN = 'user@contoso.com' } -Changes @{ Department = 'Sales' } -Actor 'admin@contoso.com' @@ -60,7 +66,7 @@ function New-IdleRequestObject { Security Considerations: - Input data must be data-only (no ScriptBlocks or executable objects). The function validates this constraint and throws if violated. - - Do not embed secrets in IdentityKeys, DesiredState, or Changes. Use the AuthSessionBroker + - Do not embed secrets in IdentityKeys, Intent, Context, or Changes. Use the AuthSessionBroker pattern for credential/token management. - Sensitive data in request objects may be logged or emitted in events. Rely on redaction boundaries defined in the engine's event sink and logging layers. @@ -84,7 +90,10 @@ function New-IdleRequestObject { [hashtable] $IdentityKeys = @{}, [Parameter()] - [hashtable] $DesiredState = @{}, + [hashtable] $Intent = @{}, + + [Parameter()] + [hashtable] $Context = @{}, [Parameter()] [hashtable] $Changes @@ -92,20 +101,23 @@ function New-IdleRequestObject { # Validate that no ScriptBlocks are present in the input data Assert-IdleNoScriptBlock -InputObject $IdentityKeys -Path 'IdentityKeys' - Assert-IdleNoScriptBlock -InputObject $DesiredState -Path 'DesiredState' + Assert-IdleNoScriptBlock -InputObject $Intent -Path 'Intent' + Assert-IdleNoScriptBlock -InputObject $Context -Path 'Context' Assert-IdleNoScriptBlock -InputObject $Changes -Path 'Changes' # Clone hashtables to avoid external mutation after object creation # shallow clone is sufficient as we have already validated no ScriptBlocks are present $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } - $DesiredState = if ($null -eq $DesiredState) { @{} } else { $DesiredState.Clone() } - $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } + $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } + $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } + $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 return [IdleLifecycleRequest]::new( $LifecycleEvent, $IdentityKeys, - $DesiredState, + $Intent, + $Context, $Changes, $CorrelationId, $Actor diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 0ce48612..830d718a 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -64,7 +64,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' @@ -127,7 +127,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { .EXAMPLE # Template usage with dynamic manager attributes (Leaver scenario): # Note: Templates are resolved during planning against the request object. - # Host must enrich request.DesiredState with manager data before calling New-IdlePlan. + # Host must enrich request.Intent with manager data before calling New-IdlePlan. # Host-side enrichment (example): # $user = Get-ADUser -Identity 'max.power' -Properties Manager @@ -140,7 +140,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # Mail = 'servicedesk@contoso.com' # } # } - # $req = New-IdleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -DesiredState @{ + # $req = New-IdleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -Intent @{ # Manager = @{ DisplayName = $mgr.DisplayName; Mail = $mgr.Mail } # } @@ -153,8 +153,8 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { IdentityKey = 'max.power@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.DisplayName}} ({{Request.DesiredState.Manager.Mail}}).' - ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.Mail}}.' + InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.Intent.Manager.DisplayName}} ({{Request.Intent.Manager.Mail}}).' + ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.Intent.Manager.Mail}}.' ExternalAudience = 'All' } } diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 1a700f7e..77f3807b 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -11,7 +11,7 @@ Provider-agnostic mailbox step pack for IdLE. Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } MailboxType = 'Shared' } } diff --git a/src/IdLE/Public/New-IdleRequest.ps1 b/src/IdLE/Public/New-IdleRequest.ps1 index 14df2119..693393e9 100644 --- a/src/IdLE/Public/New-IdleRequest.ps1 +++ b/src/IdLE/Public/New-IdleRequest.ps1 @@ -20,15 +20,25 @@ function New-IdleRequest { .PARAMETER IdentityKeys A hashtable of system-neutral identity keys (e.g. EmployeeId, UPN, ObjectId). - .PARAMETER DesiredState - A hashtable describing the desired state (attributes, entitlements, etc.). + .PARAMETER Intent + A hashtable containing the caller-provided action inputs for the workflow (attributes, + entitlements, operator flags, etc.). + + .PARAMETER Context + A hashtable containing read-only associated context provided by the host or resolvers + (e.g. identity snapshots, device hints). Must not be treated as mutable state within IdLE. .PARAMETER Changes Optional hashtable describing changes (typically used for Mover lifecycle events). .EXAMPLE + # Minimal Joiner request — CorrelationId is auto-generated, Intent/Context default to empty New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } + .EXAMPLE + # Joiner request with caller-provided action inputs (Intent) and read-only associated context (Context) + New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } -Intent @{ Department = 'Engineering'; Title = 'Engineer' } -Context @{ Identity = @{ ObjectId = 'abc-123' } } + .OUTPUTS IdleLifecycleRequest #> @@ -48,7 +58,10 @@ function New-IdleRequest { [hashtable] $IdentityKeys = @{}, [Parameter()] - [hashtable] $DesiredState = @{}, + [hashtable] $Intent = @{}, + + [Parameter()] + [hashtable] $Context = @{}, [Parameter()] [hashtable] $Changes diff --git a/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 b/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 index 9944c117..6aeb5204 100644 --- a/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 +++ b/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 @@ -217,11 +217,11 @@ Describe 'Assert-IdleNoScriptBlock' { Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*' } - It 'rejects lifecycle request with ScriptBlock in DesiredState' { + It 'rejects lifecycle request with ScriptBlock in Intent' { $request = @{ LifecycleEvent = 'Joiner' CorrelationId = 'test-123' - DesiredState = @{ + Intent = @{ Identity = @{ BadProperty = { Get-Credential } } @@ -229,7 +229,7 @@ Describe 'Assert-IdleNoScriptBlock' { } { Assert-IdleNoScriptBlock -InputObject $request -Path 'Request' } | - Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Request.DesiredState.Identity.BadProperty*' + Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Request.Intent.Identity.BadProperty*' } } diff --git a/tests/Core/CapabilityDeprecation.Tests.ps1 b/tests/Core/CapabilityDeprecation.Tests.ps1 index ad99a6b8..103a44d1 100644 --- a/tests/Core/CapabilityDeprecation.Tests.ps1 +++ b/tests/Core/CapabilityDeprecation.Tests.ps1 @@ -32,7 +32,7 @@ Describe 'Capability Deprecation and Migration' { # Verify the workflow file exists $wfPath | Should -Exist - $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -Intent @{ UserPrincipalName = 'leaver@contoso.com' Manager = @{ DisplayName = 'IT Support' @@ -77,7 +77,7 @@ Describe 'Capability Deprecation and Migration' { # Use a real workflow file $wfPath = Join-Path $PSScriptRoot '..' '..' 'examples' 'workflows' 'templates' 'exo-leaver.psd1' - $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -Intent @{ UserPrincipalName = 'leaver@contoso.com' Manager = @{ DisplayName = 'IT Support' diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 8a550491..b8482b14 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -30,7 +30,7 @@ Describe 'Export-IdlePlan' { -LifecycleEvent 'Joiner' ` -CorrelationId $cid ` -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` - -DesiredState ([ordered]@{ department = 'IT' }) + -Intent ([ordered]@{ department = 'IT' }) $providers = @{ Dummy = $true diff --git a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 index 83328af9..49391b06 100644 --- a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 @@ -68,8 +68,8 @@ Describe 'Mailbox OutOfOffice step - template resolution' { IdentityKey = 'user@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'Please contact {{Request.DesiredState.Manager.DisplayName}} at {{Request.DesiredState.Manager.Mail}}.' - ExternalMessage = 'Please contact {{Request.DesiredState.Manager.Mail}}.' + InternalMessage = 'Please contact {{Request.Intent.Manager.DisplayName}} at {{Request.Intent.Manager.Mail}}.' + ExternalMessage = 'Please contact {{Request.Intent.Manager.Mail}}.' ExternalAudience = 'All' } } @@ -81,7 +81,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $req = New-IdleTestRequest ` -LifecycleEvent 'Leaver' ` -Actor 'admin@contoso.com' ` - -DesiredState @{ + -Intent @{ Manager = @{ DisplayName = 'Jane Smith' Mail = 'jane.smith@contoso.com' @@ -107,7 +107,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $mailbox.OOFExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' } - It 'resolves nested template variables from Request.DesiredState' { + It 'resolves nested template variables from Request.Intent' { $wfPath = New-IdleTestWorkflowFile -FileName 'oof-nested-templates.psd1' -Content @' @{ Name = 'OOF with Nested Templates' @@ -121,8 +121,8 @@ Describe 'Mailbox OutOfOffice step - template resolution' { IdentityKey = 'user@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'User has left. Contact: {{Request.DesiredState.Handover.Name}} ({{Request.DesiredState.Handover.Email}})' - ExternalMessage = 'For assistance: {{Request.DesiredState.Handover.Email}}' + InternalMessage = 'User has left. Contact: {{Request.Intent.Handover.Name}} ({{Request.Intent.Handover.Email}})' + ExternalMessage = 'For assistance: {{Request.Intent.Handover.Email}}' } } } @@ -133,7 +133,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $req = New-IdleTestRequest ` -LifecycleEvent 'Leaver' ` -Actor 'admin@contoso.com' ` - -DesiredState @{ + -Intent @{ Handover = @{ Name = 'Bob Johnson' Email = 'bob.johnson@contoso.com' @@ -160,8 +160,8 @@ Describe 'Mailbox OutOfOffice step - template resolution' { IdentityKey = 'user@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'Contact {{Request.DesiredState.TeamLead.Name}}' - ExternalMessage = 'Email {{Request.DesiredState.TeamLead.Email}}' + InternalMessage = 'Contact {{Request.Intent.TeamLead.Name}}' + ExternalMessage = 'Email {{Request.Intent.TeamLead.Email}}' } } } @@ -172,7 +172,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $req = New-IdleTestRequest ` -LifecycleEvent 'Leaver' ` -Actor 'admin@contoso.com' ` - -DesiredState @{ + -Intent @{ TeamLead = @{ Name = 'Alice Brown' Email = 'alice.brown@contoso.com' diff --git a/tests/Core/New-IdleRequest.Tests.ps1 b/tests/Core/New-IdleRequest.Tests.ps1 index c71ed887..647c7acd 100644 --- a/tests/Core/New-IdleRequest.Tests.ps1 +++ b/tests/Core/New-IdleRequest.Tests.ps1 @@ -25,12 +25,23 @@ Describe 'New-IdleRequest' { $req.CorrelationId | Should -Be $cid } - It 'defaults IdentityKeys and DesiredState to empty hashtables when omitted' { + It 'defaults IdentityKeys to an empty hashtable when omitted' { $req = New-IdleRequest -LifecycleEvent 'Joiner' $req.IdentityKeys | Should -BeOfType 'hashtable' - $req.DesiredState | Should -BeOfType 'hashtable' $req.IdentityKeys.Count | Should -Be 0 - $req.DesiredState.Count | Should -Be 0 + } + + It 'defaults Intent and Context to empty hashtables when omitted' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.Intent | Should -BeOfType 'hashtable' + $req.Context | Should -BeOfType 'hashtable' + $req.Intent.Count | Should -Be 0 + $req.Context.Count | Should -Be 0 + } + + It 'does not expose a DesiredState property' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.PSObject.Properties.Name | Should -Not -Contain 'DesiredState' } } @@ -65,42 +76,55 @@ Describe 'New-IdleRequest' { $req.Actor | Should -Be 'alice@contoso.com' } } + + Context 'Intent parameter' { + It 'accepts -Intent and populates Intent property' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Department = 'Engineering' } + $req.Intent | Should -BeOfType 'hashtable' + $req.Intent.Department | Should -Be 'Engineering' + } + } + + Context 'Context parameter' { + It 'accepts -Context and populates Context property' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Context @{ Identity = @{ ObjectId = 'abc-123' } } + $req.Context | Should -BeOfType 'hashtable' + $req.Context.Identity.ObjectId | Should -Be 'abc-123' + } + } } Describe 'New-IdleRequest - data-only validation' { Context 'ScriptBlock rejection' { - It 'rejects ScriptBlock in DesiredState when provided' { - try { - New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + It 'rejects ScriptBlock in Intent when provided' { + { + New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Attributes = @{ Department = { 'IT' } } } - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'DesiredState' - } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' } It 'rejects ScriptBlock nested in arrays' { - try { - New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + { + New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Entitlements = @( @{ Type = 'Group'; Value = 'APP-CRM-Users' } @{ Type = 'Custom'; Value = { 'NOPE' } } ) } - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'DesiredState' - } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' + } + + It 'rejects ScriptBlock in Context when provided' { + { + New-IdleRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ Value = { 'NOPE' } } + } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' } It 'rejects ScriptBlock in Changes when provided' { - try { + { New-IdleRequest -LifecycleEvent 'Joiner' -Changes @{ Attributes = @{ Department = @{ @@ -109,15 +133,48 @@ Describe 'New-IdleRequest - data-only validation' { } } } - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'Changes' - } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*Changes*' } } } +Describe 'New-IdlePlan - Request.Identity rejection' { + BeforeAll { + function global:Invoke-IdleTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNull()][object] $Context, + [Parameter(Mandatory)][ValidateNotNull()][object] $Step + ) + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + } + + AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue + } + + It 'rejects a request object that contains an Identity property' { + $badRequest = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + LifecycleEvent = 'Joiner' + CorrelationId = [guid]::NewGuid().ToString() + Identity = @{ ObjectId = 'abc-123' } + } + + $wfPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows/template-tests/template-simple.psd1' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + { New-IdlePlan -WorkflowPath $wfPath -Request $badRequest -Providers $providers } | + Should -Throw -ExpectedMessage "*must not contain property 'Identity'*" + } +} diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 39aa3060..160ba5a5 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -39,10 +39,10 @@ AfterAll { Describe 'Template Substitution' { Context 'Single placeholder substitution' { - It 'resolves a simple Request.Input placeholder' { + It 'resolves a simple Request.Intent placeholder' { $wfPath = Get-TemplateTestFixture 'template-simple' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ UserPrincipalName = 'jdoe@example.com' } $providers = @{ @@ -57,10 +57,10 @@ Describe 'Template Substitution' { $plan.Steps[0].With.UserName | Should -Be 'jdoe@example.com' } - It 'resolves Request.DesiredState placeholder directly' { - $wfPath = Get-TemplateTestFixture 'template-desiredstate' + It 'resolves Request.Intent placeholder' { + $wfPath = Get-TemplateTestFixture 'template-intent' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Department = 'Engineering' } $providers = @{ @@ -74,13 +74,31 @@ Describe 'Template Substitution' { $plan.Steps[0].With.Department | Should -Be 'Engineering' } + + It 'resolves Request.Context placeholder' { + $wfPath = Get-TemplateTestFixture 'template-context' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ ObjectId = 'obj-abc-123' } + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.ObjectId | Should -Be 'obj-abc-123' + } } Context 'Multiple placeholders in one string' { - It 'resolves multiple placeholders in a single string' { + It 'resolves multiple Intent placeholders in a single string' { $wfPath = Get-TemplateTestFixture 'template-multiple' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ DisplayName = 'John Doe' UserPrincipalName = 'jdoe@example.com' } @@ -95,13 +113,34 @@ Describe 'Template Substitution' { $plan.Steps[0].With.Message | Should -Be 'User John Doe (jdoe@example.com) is joining.' } + + It 'resolves multiple Context placeholders in a single string' { + $wfPath = Get-TemplateTestFixture 'template-context-multiple' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ + DisplayName = 'Jane Smith' + ObjectId = 'abc-123' + } + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Message | Should -Be 'Identity Jane Smith (abc-123) loaded.' + } } Context 'Nested hashtable and array substitution' { - It 'resolves templates in nested hashtables' { + It 'resolves Intent templates in nested hashtables' { $wfPath = Get-TemplateTestFixture 'template-nested-hash' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ DisplayName = 'Jane Smith' Mail = 'jsmith@example.com' } @@ -118,10 +157,32 @@ Describe 'Template Substitution' { $plan.Steps[0].With.User.Email | Should -Be 'jsmith@example.com' } + It 'resolves Context templates in nested hashtables' { + $wfPath = Get-TemplateTestFixture 'template-context-nested-hash' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ + DisplayName = 'Alice Brown' + Mail = 'alice.brown@example.com' + } + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Identity.Name | Should -Be 'Alice Brown' + $plan.Steps[0].With.Identity.Email | Should -Be 'alice.brown@example.com' + } + It 'resolves templates in arrays' { $wfPath = Get-TemplateTestFixture 'template-array' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ PrimaryEmail = 'primary@example.com' SecondaryEmail = 'secondary@example.com' } @@ -143,7 +204,7 @@ Describe 'Template Substitution' { It 'throws on unbalanced opening brace' { $wfPath = Get-TemplateTestFixture 'template-unbalanced-open' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -156,7 +217,7 @@ Describe 'Template Substitution' { It 'throws on unbalanced closing brace' { $wfPath = Get-TemplateTestFixture 'template-unbalanced-close' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -171,7 +232,7 @@ Describe 'Template Substitution' { It 'throws on path with spaces' { $wfPath = Get-TemplateTestFixture 'template-path-spaces' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ UserName = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -184,7 +245,7 @@ Describe 'Template Substitution' { It 'throws on path with special characters' { $wfPath = Get-TemplateTestFixture 'template-path-special' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ UserName = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -196,10 +257,23 @@ Describe 'Template Substitution' { } Context 'Missing path segments' { - It 'throws when path does not exist' { + It 'throws when Intent path does not exist' { $wfPath = Get-TemplateTestFixture 'template-missing-path' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*resolved to null or does not exist*' + } + + It 'throws when Context path does not exist' { + $wfPath = Get-TemplateTestFixture 'template-context-missing-path' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ Identity = @{ ObjectId = 'abc' } } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -214,7 +288,7 @@ Describe 'Template Substitution' { It 'throws when resolved value is null' { $wfPath = Get-TemplateTestFixture 'template-null-value' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ NullField = $null } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ NullField = $null } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -229,7 +303,7 @@ Describe 'Template Substitution' { It 'throws when accessing Plan root' { $wfPath = Get-TemplateTestFixture 'template-plan-root' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -242,7 +316,7 @@ Describe 'Template Substitution' { It 'throws when accessing Providers root' { $wfPath = Get-TemplateTestFixture 'template-providers-root' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -255,7 +329,7 @@ Describe 'Template Substitution' { It 'throws when accessing Workflow root' { $wfPath = Get-TemplateTestFixture 'template-workflow-root' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -266,53 +340,11 @@ Describe 'Template Substitution' { } } - Context 'Request.Input alias behavior' { - It 'uses Request.Input when Input property exists' { - $wfPath = Get-TemplateTestFixture 'template-input-exists' - - # Create a request with explicit Input property - $req = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequest' - LifecycleEvent = 'Joiner' - CorrelationId = [guid]::NewGuid().ToString() - Input = @{ Name = 'FromInput' } - DesiredState = @{ Name = 'FromDesiredState' } - } - - $providers = @{ - StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan.Steps[0].With.Value | Should -Be 'FromInput' - } - - It 'aliases Request.Input to Request.DesiredState when Input does not exist' { - $wfPath = Get-TemplateTestFixture 'template-input-alias' - - # Use standard request without explicit Input property - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Name = 'FromDesiredState' - } - - $providers = @{ - StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan.Steps[0].With.Value | Should -Be 'FromDesiredState' - } - } - Context 'Escaping' { It 'handles escaped opening braces' { $wfPath = Get-TemplateTestFixture 'template-escaped' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -326,7 +358,7 @@ Describe 'Template Substitution' { It 'handles escaped braces mixed with templates' { $wfPath = Get-TemplateTestFixture 'template-escaped-mixed' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'TestName' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'TestName' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -357,7 +389,7 @@ Describe 'Template Substitution' { # A loose lookahead would let this through to template parsing → wrong "path not allowed" error. $wfPath = Get-TemplateTestFixture 'template-escaped-invalid-root' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -372,7 +404,7 @@ Describe 'Template Substitution' { It 'resolves templates in OnFailureSteps' { $wfPath = Get-TemplateTestFixture 'template-onfailure' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'John Doe' UserPrincipalName = 'jdoe@example.com' } @@ -394,7 +426,7 @@ Describe 'Template Substitution' { It 'allows Request.LifecycleEvent' { $wfPath = Get-TemplateTestFixture 'template-lifecycle-event' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -408,7 +440,7 @@ Describe 'Template Substitution' { It 'allows Request.CorrelationId' { $wfPath = Get-TemplateTestFixture 'template-correlation-id' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -422,7 +454,7 @@ Describe 'Template Substitution' { It 'allows Request.Actor' { $wfPath = Get-TemplateTestFixture 'template-actor' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } -Actor 'admin@example.com' + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } -Actor 'admin@example.com' $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -438,7 +470,7 @@ Describe 'Template Substitution' { It 'resolves numeric types to strings' { $wfPath = Get-TemplateTestFixture 'template-numeric' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ UserId = 12345 } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -452,7 +484,7 @@ Describe 'Template Substitution' { It 'resolves boolean types to strings' { $wfPath = Get-TemplateTestFixture 'template-boolean' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsEnabled = $true } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ IsEnabled = $true } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -466,7 +498,7 @@ Describe 'Template Substitution' { It 'throws when resolving to a hashtable' { $wfPath = Get-TemplateTestFixture 'template-hashtable' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ UserData = @{ Name = 'John'; Age = 30 } } $providers = @{ @@ -481,7 +513,7 @@ Describe 'Template Substitution' { It 'throws when resolving to an array' { $wfPath = Get-TemplateTestFixture 'template-array-value' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Tags = @('tag1', 'tag2') } $providers = @{ @@ -498,7 +530,7 @@ Describe 'Template Substitution' { It 'preserves boolean false type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-boolean-false' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Enabled = $false } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -515,7 +547,7 @@ Describe 'Template Substitution' { It 'preserves boolean true type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-boolean-true' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsActive = $true } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ IsActive = $true } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -530,7 +562,7 @@ Describe 'Template Substitution' { It 'preserves integer type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-integer' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ UserId = 12345 } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -546,7 +578,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-pure-datetime' $testDate = Get-Date '2026-01-15T10:00:00' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ StartDate = $testDate } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ StartDate = $testDate } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -562,7 +594,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-pure-guid' $testGuid = [guid]::NewGuid() - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ ObjectId = $testGuid } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ ObjectId = $testGuid } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -577,7 +609,7 @@ Describe 'Template Substitution' { It 'converts to string for mixed template (string interpolation)' { $wfPath = Get-TemplateTestFixture 'template-mixed-boolean' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Enabled = $false } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index 2e5e8ded..195f3342 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -80,7 +80,10 @@ function New-IdleTestRequest { [hashtable] $IdentityKeys, [Parameter()] - [hashtable] $DesiredState, + [hashtable] $Intent, + + [Parameter()] + [hashtable] $Context, [Parameter()] [hashtable] $Changes, @@ -95,10 +98,11 @@ function New-IdleTestRequest { $params = @{ LifecycleEvent = $LifecycleEvent } if ($PSBoundParameters.ContainsKey('IdentityKeys')) { $params.IdentityKeys = $IdentityKeys } - if ($PSBoundParameters.ContainsKey('DesiredState')) { $params.DesiredState = $DesiredState } - if ($PSBoundParameters.ContainsKey('Changes')) { $params.Changes = $Changes } + if ($PSBoundParameters.ContainsKey('Intent')) { $params.Intent = $Intent } + if ($PSBoundParameters.ContainsKey('Context')) { $params.Context = $Context } + if ($PSBoundParameters.ContainsKey('Changes')) { $params.Changes = $Changes } if ($PSBoundParameters.ContainsKey('CorrelationId')) { $params.CorrelationId = $CorrelationId } - if ($PSBoundParameters.ContainsKey('Actor')) { $params.Actor = $Actor } + if ($PSBoundParameters.ContainsKey('Actor')) { $params.Actor = $Actor } return New-IdleRequest @params } diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index 5f2e5876..1a4bea62 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -11,9 +11,10 @@ "identityKeys": { "userId": "jdoe" }, - "desiredState": { + "intent": { "department": "IT" }, + "context": {}, "changes": null } }, diff --git a/tests/fixtures/workflows/template-tests/template-array-value.psd1 b/tests/fixtures/workflows/template-tests/template-array-value.psd1 index 312ebaed..08787e22 100644 --- a/tests/fixtures/workflows/template-tests/template-array-value.psd1 +++ b/tests/fixtures/workflows/template-tests/template-array-value.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Tags}}' + Value = '{{Request.Intent.Tags}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-array.psd1 b/tests/fixtures/workflows/template-tests/template-array.psd1 index 1a26a4a0..39daca96 100644 --- a/tests/fixtures/workflows/template-tests/template-array.psd1 +++ b/tests/fixtures/workflows/template-tests/template-array.psd1 @@ -7,8 +7,8 @@ Type = 'IdLE.Step.Test' With = @{ Emails = @( - '{{Request.Input.PrimaryEmail}}' - '{{Request.Input.SecondaryEmail}}' + '{{Request.Intent.PrimaryEmail}}' + '{{Request.Intent.SecondaryEmail}}' ) } } diff --git a/tests/fixtures/workflows/template-tests/template-boolean.psd1 b/tests/fixtures/workflows/template-tests/template-boolean.psd1 index e97ff909..76467092 100644 --- a/tests/fixtures/workflows/template-tests/template-boolean.psd1 +++ b/tests/fixtures/workflows/template-tests/template-boolean.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Enabled: {{Request.Input.IsEnabled}}' + Value = 'Enabled: {{Request.Intent.IsEnabled}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 b/tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 new file mode 100644 index 00000000..e370e1d0 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Context Missing Path' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Context.NonExistent}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-context-multiple.psd1 b/tests/fixtures/workflows/template-tests/template-context-multiple.psd1 new file mode 100644 index 00000000..a7d6a241 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context-multiple.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Context Multiple' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Message = 'Identity {{Request.Context.Identity.DisplayName}} ({{Request.Context.Identity.ObjectId}}) loaded.' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 b/tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 new file mode 100644 index 00000000..db2c4dfd --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Template Test - Context Nested Hash' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Identity = @{ + Name = '{{Request.Context.Identity.DisplayName}}' + Email = '{{Request.Context.Identity.Mail}}' + } + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-context.psd1 b/tests/fixtures/workflows/template-tests/template-context.psd1 new file mode 100644 index 00000000..93b7e423 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Context' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + ObjectId = '{{Request.Context.Identity.ObjectId}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 b/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 index c7023998..7145a15f 100644 --- a/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 +++ b/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Department = '{{Request.DesiredState.Department}}' + Department = '{{Request.Intent.Department}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 index 9c183fbf..e61e3416 100644 --- a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 +++ b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Literal \{{ and template {{Request.Input.Name}}' + Value = 'Literal \{{ and template {{Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-hashtable.psd1 b/tests/fixtures/workflows/template-tests/template-hashtable.psd1 index 63f86c99..fb5219aa 100644 --- a/tests/fixtures/workflows/template-tests/template-hashtable.psd1 +++ b/tests/fixtures/workflows/template-tests/template-hashtable.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.UserData}}' + Value = '{{Request.Intent.UserData}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-input-alias.psd1 b/tests/fixtures/workflows/template-tests/template-input-alias.psd1 index e7cce502..f6ac18fa 100644 --- a/tests/fixtures/workflows/template-tests/template-input-alias.psd1 +++ b/tests/fixtures/workflows/template-tests/template-input-alias.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name}}' + Value = '{{Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-input-exists.psd1 b/tests/fixtures/workflows/template-tests/template-input-exists.psd1 index 14f6c2ea..c9cd0159 100644 --- a/tests/fixtures/workflows/template-tests/template-input-exists.psd1 +++ b/tests/fixtures/workflows/template-tests/template-input-exists.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name}}' + Value = '{{Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-intent.psd1 b/tests/fixtures/workflows/template-tests/template-intent.psd1 new file mode 100644 index 00000000..fb8f9128 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-intent.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Intent' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Department = '{{Request.Intent.Department}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-missing-path.psd1 b/tests/fixtures/workflows/template-tests/template-missing-path.psd1 index fde9f978..d9bc6788 100644 --- a/tests/fixtures/workflows/template-tests/template-missing-path.psd1 +++ b/tests/fixtures/workflows/template-tests/template-missing-path.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.NonExistent}}' + Value = '{{Request.Intent.NonExistent}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 b/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 index 6dd771d9..21c57c37 100644 --- a/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 +++ b/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Message = 'Account enabled: {{Request.Input.Enabled}}' + Message = 'Account enabled: {{Request.Intent.Enabled}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-multiple.psd1 b/tests/fixtures/workflows/template-tests/template-multiple.psd1 index a517dbb3..652395d2 100644 --- a/tests/fixtures/workflows/template-tests/template-multiple.psd1 +++ b/tests/fixtures/workflows/template-tests/template-multiple.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Message = 'User {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}}) is joining.' + Message = 'User {{Request.Intent.DisplayName}} ({{Request.Intent.UserPrincipalName}}) is joining.' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 b/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 index ebcd065d..b6c4de47 100644 --- a/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 +++ b/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 @@ -7,8 +7,8 @@ Type = 'IdLE.Step.Test' With = @{ User = @{ - Name = '{{Request.Input.DisplayName}}' - Email = '{{Request.Input.Mail}}' + Name = '{{Request.Intent.DisplayName}}' + Email = '{{Request.Intent.Mail}}' } } } diff --git a/tests/fixtures/workflows/template-tests/template-null-value.psd1 b/tests/fixtures/workflows/template-tests/template-null-value.psd1 index 0934e8c4..64813312 100644 --- a/tests/fixtures/workflows/template-tests/template-null-value.psd1 +++ b/tests/fixtures/workflows/template-tests/template-null-value.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.NullField}}' + Value = '{{Request.Intent.NullField}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-numeric.psd1 b/tests/fixtures/workflows/template-tests/template-numeric.psd1 index 8e764866..f323824b 100644 --- a/tests/fixtures/workflows/template-tests/template-numeric.psd1 +++ b/tests/fixtures/workflows/template-tests/template-numeric.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'ID: {{Request.Input.UserId}}' + Value = 'ID: {{Request.Intent.UserId}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-onfailure.psd1 b/tests/fixtures/workflows/template-tests/template-onfailure.psd1 index a19fe396..584d1088 100644 --- a/tests/fixtures/workflows/template-tests/template-onfailure.psd1 +++ b/tests/fixtures/workflows/template-tests/template-onfailure.psd1 @@ -6,7 +6,7 @@ Name = 'MainStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name}}' + Value = '{{Request.Intent.Name}}' } } ) @@ -15,7 +15,7 @@ Name = 'FailureHandler' Type = 'IdLE.Step.Test' With = @{ - ErrorMessage = 'Failed for user {{Request.Input.UserPrincipalName}}' + ErrorMessage = 'Failed for user {{Request.Intent.UserPrincipalName}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 b/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 index 876498db..8e5c702c 100644 --- a/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 +++ b/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.User Name}}' + Value = '{{Request.Intent.User Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-path-special.psd1 b/tests/fixtures/workflows/template-tests/template-path-special.psd1 index a8998840..84fc0b4d 100644 --- a/tests/fixtures/workflows/template-tests/template-path-special.psd1 +++ b/tests/fixtures/workflows/template-tests/template-path-special.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.User@Name}}' + Value = '{{Request.Intent.User@Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 b/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 index 5546d9a9..32e31193 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Enabled = '{{Request.Input.Enabled}}' + Enabled = '{{Request.Intent.Enabled}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 b/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 index de4cf3a7..3d933d7c 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - IsActive = '{{Request.Input.IsActive}}' + IsActive = '{{Request.Intent.IsActive}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 b/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 index 0d06345e..844a5b1d 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - StartDate = '{{Request.Input.StartDate}}' + StartDate = '{{Request.Intent.StartDate}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 b/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 index f4c66540..3a2f4176 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - ObjectId = '{{Request.Input.ObjectId}}' + ObjectId = '{{Request.Intent.ObjectId}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 b/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 index b9292f99..65705f6b 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - UserId = '{{Request.Input.UserId}}' + UserId = '{{Request.Intent.UserId}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-simple.psd1 b/tests/fixtures/workflows/template-tests/template-simple.psd1 index bba47ec9..7cdfbdf8 100644 --- a/tests/fixtures/workflows/template-tests/template-simple.psd1 +++ b/tests/fixtures/workflows/template-tests/template-simple.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - UserName = '{{Request.Input.UserPrincipalName}}' + UserName = '{{Request.Intent.UserPrincipalName}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 b/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 index cbe8a751..f9f4ae40 100644 --- a/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 +++ b/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Request.Input.Name}}' + Value = 'Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 b/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 index 3b5d51f0..e6e3e6db 100644 --- a/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 +++ b/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name' + Value = '{{Request.Intent.Name' } } )