Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/extend/extensibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `Intent`, `Context`, `Changes`
- Optional fields: `Actor`, `IdentityKeys`, `Intent`, `Context`
18 changes: 1 addition & 17 deletions docs/reference/cmdlets/New-IdleRequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Creates a lifecycle request object.

```
New-IdleRequest [-LifecycleEvent] <String> [[-CorrelationId] <String>] [[-Actor] <String>]
[[-IdentityKeys] <Hashtable>] [[-Intent] <Hashtable>] [[-Context] <Hashtable>] [[-Changes] <Hashtable>]
[[-IdentityKeys] <Hashtable>] [[-Intent] <Hashtable>] [[-Context] <Hashtable>]
[-ProgressAction <ActionPreference>] [<CommonParameters>]
```

Expand All @@ -24,7 +24,6 @@ Creates and normalizes an IdLE LifecycleRequest representing business intent
Joiner/Mover/Leaver).
CorrelationId is generated if missing.
Actor is optional.
Changes is optional and stays $null when omitted.

## EXAMPLES

Expand Down Expand Up @@ -140,21 +139,6 @@ Accept pipeline input: False
Accept wildcard characters: False
```

### -Changes
Optional hashtable describing changes (typically used for Mover lifecycle events).

```yaml
Type: Hashtable
Parameter Sets: (All)
Aliases:

Required: False
Position: 7
Default value: None
Accept pipeline input: False
Accept wildcard characters: False
```

### -ProgressAction
TODO: ProgressAction Description

Expand Down
4 changes: 1 addition & 3 deletions docs/reference/specs/plan-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ The request object captures *why* a plan was created, independent of *how* it wi
},
"desiredState": {
"department": "IT"
},
"changes": null
}
}
}
```
Expand All @@ -122,7 +121,6 @@ The request object captures *why* a plan was created, independent of *how* it wi
- For **IdLE-native lifecycle requests**, `input` SHOULD contain:
- `identityKeys` – identifiers of the target identity
- `desiredState` – intended target state
- `changes` – explicit deltas, if applicable
- Hosts MAY include additional fields in `input`.
- The request payload is exported for **audit, approval, and traceability purposes** and MUST remain stable once the plan is created.

Expand Down
1 change: 0 additions & 1 deletion docs/use/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ For security, only these path roots are permitted:
| `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 |
Expand Down
6 changes: 2 additions & 4 deletions src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,17 @@ 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/Intent/Context/Changes.
# IdLE lifecycle requests store business intent as IdentityKeys/Intent/Context.
# When present, export these as the canonical request.input payload.
$identityKeys = Get-FirstPropertyValue -Object $request -Names @('IdentityKeys', 'IdentityKey', 'Keys')
$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 $intent -or $null -ne $context -or $null -ne $changes) {
if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $context) {
$requestInput = New-OrderedMap
$requestInput.identityKeys = $identityKeys
$requestInput.intent = $intent
$requestInput.context = $context
$requestInput.changes = $changes
}
}
}
Expand Down
9 changes: 0 additions & 9 deletions src/IdLE.Core/Private/IdleLifecycleRequest.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Domain model: LifecycleRequest
# Actor is intentionally optional in V1 (see architecture).
# 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.
Expand All @@ -10,7 +9,6 @@ class IdleLifecycleRequest {
[hashtable] $IdentityKeys
[hashtable] $Intent
[hashtable] $Context
[hashtable] $Changes
[string] $CorrelationId
[string] $Actor

Expand All @@ -19,15 +17,13 @@ class IdleLifecycleRequest {
[hashtable] $identityKeys,
[hashtable] $intent,
[hashtable] $context,
[hashtable] $changes,
[string] $correlationId,
[string] $actor
) {
$this.LifecycleEvent = $lifecycleEvent
$this.IdentityKeys = $identityKeys
$this.Intent = $intent
$this.Context = $context
$this.Changes = $changes
$this.CorrelationId = $correlationId
$this.Actor = $actor

Expand All @@ -51,11 +47,6 @@ class IdleLifecycleRequest {
$this.Context = @{}
}

# Changes stays $null if not provided. If provided, it must be a hashtable.
if ($null -ne $this.Changes -and $this.Changes -isnot [hashtable]) {
throw [System.ArgumentException]::new('Changes must be a hashtable when provided.', 'Changes')
}

if ([string]::IsNullOrWhiteSpace($this.CorrelationId)) {
$this.CorrelationId = [guid]::NewGuid().Guid
}
Expand Down
5 changes: 2 additions & 3 deletions src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ function Resolve-IdleTemplateString {
- Request.Intent.* (canonical caller-provided action inputs)
- Request.Context.* (read-only associated context)
- Request.IdentityKeys.*
- Request.Changes.*
- Request.LifecycleEvent
- Request.CorrelationId
- Request.Actor
Expand Down Expand Up @@ -72,7 +71,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.Intent', 'Request.Context', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor')
$allowedRoots = @('Request.Intent', 'Request.Context', 'Request.IdentityKeys', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor')

# Helper function to validate path pattern
$validatePath = {
Expand Down Expand Up @@ -188,7 +187,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\.(?:(?:Intent|Context|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})'
$backslashEscapePattern = '\\{{(?!Request\.(?:(?:Intent|Context|IdentityKeys)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})'
$normalizedValue = if ($stringValue -match '\\{{') {
[regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder)
} else {
Expand Down
1 change: 0 additions & 1 deletion src/IdLE.Core/Public/New-IdlePlanObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ function New-IdlePlanObject {
IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } 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 }
Comment thread
blindzero marked this conversation as resolved.
Changes = if ($reqProps -contains 'Changes') { Copy-IdleDataObject -Value $Request.Changes } else { $null }
}

# Validate workflow and ensure it matches the request's LifecycleEvent.
Expand Down
20 changes: 5 additions & 15 deletions src/IdLE.Core/Public/New-IdleRequestObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Intent, Context, Changes) to enforce the data-only configuration principle. Input hashtables
Intent, Context) 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
Expand Down Expand Up @@ -40,10 +40,6 @@ function New-IdleRequestObject {
(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
what changed from the previous state). Remains $null when omitted. Must not contain ScriptBlocks.

.EXAMPLE
$request = New-IdleRequestObject -LifecycleEvent 'Joiner'

Expand All @@ -55,9 +51,9 @@ function New-IdleRequestObject {
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'
$request = New-IdleRequestObject -LifecycleEvent 'Mover' -IdentityKeys @{ UPN = 'user@contoso.com' } -Intent @{ NewDepartment = 'Sales' } -Actor 'admin@contoso.com'

Creates a Mover request with identity keys, changes, and actor information for a department transfer workflow.
Creates a Mover request with identity keys, intent, and actor information for a department transfer workflow.

.OUTPUTS
IdleLifecycleRequest
Expand All @@ -66,7 +62,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, Intent, Context, or Changes. Use the AuthSessionBroker
- Do not embed secrets in IdentityKeys, Intent, or Context. 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.
Expand All @@ -93,32 +89,26 @@ function New-IdleRequestObject {
[hashtable] $Intent = @{},

[Parameter()]
[hashtable] $Context = @{},

[Parameter()]
[hashtable] $Changes
[hashtable] $Context = @{}
)

# Validate that no ScriptBlocks are present in the input data
Assert-IdleNoScriptBlock -InputObject $IdentityKeys -Path 'IdentityKeys'
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() }
$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,
$Intent,
$Context,
$Changes,
$CorrelationId,
$Actor
)
Expand Down
9 changes: 1 addition & 8 deletions src/IdLE/Public/New-IdleRequest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ function New-IdleRequest {
.DESCRIPTION
Creates and normalizes an IdLE LifecycleRequest representing business intent
(e.g. Joiner/Mover/Leaver). CorrelationId is generated if missing. Actor is optional.
Changes is optional and stays $null when omitted.

.PARAMETER LifecycleEvent
The lifecycle event name (e.g. Joiner, Mover, Leaver).
Expand All @@ -28,9 +27,6 @@ function New-IdleRequest {
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' }
Expand Down Expand Up @@ -61,10 +57,7 @@ function New-IdleRequest {
[hashtable] $Intent = @{},

[Parameter()]
[hashtable] $Context = @{},

[Parameter()]
[hashtable] $Changes
[hashtable] $Context = @{}
)

# Use core-exported factory to construct the domain object. Keeps domain model inside IdLE.Core.
Expand Down
33 changes: 0 additions & 33 deletions tests/Core/New-IdleRequest.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,6 @@ Describe 'New-IdleRequest' {
}

Context 'Optional properties' {
It 'leaves Changes as null when omitted' {
$req = New-IdleRequest -LifecycleEvent 'Mover'
$req.Changes | Should -BeNullOrEmpty
}

It 'accepts Changes when provided' {
$req = New-IdleRequest -LifecycleEvent 'Mover' -Changes @{
Attributes = @{
Department = @{
From = 'Sales'
To = 'IT'
}
}
}

$req.Changes | Should -BeOfType 'hashtable'
$req.Changes.Attributes.Department.From | Should -Be 'Sales'
$req.Changes.Attributes.Department.To | Should -Be 'IT'
}

It 'treats Actor as optional (null when omitted)' {
$req = New-IdleRequest -LifecycleEvent 'Joiner'
$req.Actor | Should -BeNullOrEmpty
Expand Down Expand Up @@ -122,19 +102,6 @@ Describe 'New-IdleRequest - data-only validation' {
}
} | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*'
}

It 'rejects ScriptBlock in Changes when provided' {
{
New-IdleRequest -LifecycleEvent 'Joiner' -Changes @{
Attributes = @{
Department = @{
From = 'Sales'
To = { 'IT' }
}
}
}
} | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*Changes*'
}
}
}

Expand Down
4 changes: 0 additions & 4 deletions tests/_testHelpers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,6 @@ function New-IdleTestRequest {
[Parameter()]
[hashtable] $Context,

[Parameter()]
[hashtable] $Changes,

[Parameter()]
[string] $CorrelationId,

Expand All @@ -100,7 +97,6 @@ function New-IdleTestRequest {
if ($PSBoundParameters.ContainsKey('IdentityKeys')) { $params.IdentityKeys = $IdentityKeys }
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 }

Expand Down
3 changes: 1 addition & 2 deletions tests/fixtures/plan-export/expected/plan-export.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
"intent": {
"department": "IT"
},
"context": {},
"changes": null
"context": {}
}
},
"plan": {
Expand Down