From a709b4df0be6f0c321f31381b28e66fe9a0fe641 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:40:34 +0000 Subject: [PATCH 01/11] Initial plan From 55eea50516e00e5805cd06881c50fb5df7f8fced Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:46:36 +0000 Subject: [PATCH 02/11] Add EnsureAttributes step with backward compatible wrapper Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Get-IdleStepRegistry.ps1 | 7 + src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 1 + src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 1 + .../Public/Get-IdleStepMetadataCatalog.ps1 | 6 + .../Public/Invoke-IdleStepEnsureAttribute.ps1 | 65 ++-- .../Invoke-IdleStepEnsureAttributes.ps1 | 173 ++++++++ .../Invoke-IdleStepEnsureAttribute.Tests.ps1 | 204 ++++++++++ .../Invoke-IdleStepEnsureAttributes.Tests.ps1 | 368 ++++++++++++++++++ 8 files changed, 791 insertions(+), 34 deletions(-) create mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 create mode 100644 tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 create mode 100644 tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 2a4cc497..81f0a0b0 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -121,6 +121,13 @@ function Get-IdleStepRegistry { } } + if (-not $registry.ContainsKey('IdLE.Step.EnsureAttributes')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureAttributes' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.EnsureAttributes'] = $handler + } + } + if (-not $registry.ContainsKey('IdLE.Step.EnsureEntitlement')) { $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureEntitlement' -ModuleName 'IdLE.Steps.Common' if (-not [string]::IsNullOrWhiteSpace($handler)) { diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 88389488..53bf0b8c 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -14,6 +14,7 @@ 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', + 'Invoke-IdleStepEnsureAttributes', 'Invoke-IdleStepEnsureEntitlement', 'Invoke-IdleStepCreateIdentity', 'Invoke-IdleStepDisableIdentity', diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 8e5c5f9b..a8ee172a 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -48,6 +48,7 @@ Export-ModuleMember -Function @( 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', 'Invoke-IdleStepEnsureAttribute', + 'Invoke-IdleStepEnsureAttributes', 'Invoke-IdleStepEnsureEntitlement', 'Invoke-IdleStepCreateIdentity', 'Invoke-IdleStepDisableIdentity', diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index cc5a95b7..5cafdf02 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -54,10 +54,16 @@ function Get-IdleStepMetadataCatalog { } # IdLE.Step.EnsureAttribute - requires identity attribute ensure capability + # [DEPRECATED] Use IdLE.Step.EnsureAttributes instead $catalog['IdLE.Step.EnsureAttribute'] = @{ RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') } + # IdLE.Step.EnsureAttributes - requires identity attribute ensure capability + $catalog['IdLE.Step.EnsureAttributes'] = @{ + RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') + } + # IdLE.Step.EnsureEntitlement - requires entitlement list and grant/revoke capabilities $catalog['IdLE.Step.EnsureEntitlement'] = @{ RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 index e7842254..5daa44dd 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 @@ -4,10 +4,15 @@ function Invoke-IdleStepEnsureAttribute { Ensures that an identity attribute matches the desired value. .DESCRIPTION - This is a provider-agnostic step. The host must supply a provider instance via - Context.Providers[]. The provider must implement an EnsureAttribute - method with the signature (IdentityKey, Name, Value) and return an object that - contains a boolean property 'Changed'. + [DEPRECATED] This step type is deprecated. Use IdLE.Step.EnsureAttributes instead. + + This is a compatibility wrapper that delegates to Invoke-IdleStepEnsureAttributes. + It converts the singular With.Name/With.Value syntax to the plural With.Attributes + hashtable format. + + The host must supply a provider instance via Context.Providers[]. + The provider must implement an EnsureAttribute method with the signature + (IdentityKey, Name, Value) and return an object that contains a boolean property 'Changed'. The step is idempotent by design: it converges state to the desired value. @@ -50,36 +55,28 @@ function Invoke-IdleStepEnsureAttribute { } } - $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } - - if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { - throw "Context does not contain a Providers hashtable." - } - if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { - throw "Context.Providers must be a hashtable." - } - if (-not $Context.Providers.ContainsKey($providerAlias)) { - throw "Provider '$providerAlias' was not supplied by the host." + # Convert singular syntax to plural format + $attributeName = [string]$with.Name + $attributeValue = $with.Value + + $pluralWith = $with.Clone() + $pluralWith.Remove('Name') + $pluralWith.Remove('Value') + $pluralWith['Attributes'] = @{ + $attributeName = $attributeValue } - - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureAttribute' ` - -MethodArguments @([string]$with.IdentityKey, [string]$with.Name, $with.Value) - - $changed = $false - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $changed = [bool]$result.Changed - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = [string]$Step.Name - Type = [string]$Step.Type - Status = 'Completed' - Changed = $changed - Error = $null + + $pluralStep = [pscustomobject]@{ + Name = $Step.Name + Type = 'IdLE.Step.EnsureAttributes' + With = $pluralWith } + + # Delegate to plural handler + $result = Invoke-IdleStepEnsureAttributes -Context $Context -Step $pluralStep + + # Preserve the original step type in the result + $result.Type = [string]$Step.Type + + return $result } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 new file mode 100644 index 00000000..7ba00504 --- /dev/null +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 @@ -0,0 +1,173 @@ +function Invoke-IdleStepEnsureAttributes { + <# + .SYNOPSIS + Ensures that multiple identity attributes match their desired values. + + .DESCRIPTION + This is a provider-agnostic step that can ensure multiple attributes in a single step. + The host must supply a provider instance via Context.Providers[]. + + Provider interaction strategy: + 1. If the provider implements EnsureAttributes(IdentityKey, AttributesHashtable), it is called once (fast path). + 2. Otherwise, the step falls back to calling EnsureAttribute(IdentityKey, Name, Value) for each attribute. + + The step is idempotent by design: it converges state to the desired values. + + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable. + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $null + if ($Step.PSObject.Properties.Name -contains 'With') { + $with = $Step.With + } + + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "EnsureAttributes requires 'With' to be a hashtable." + } + + if (-not $with.ContainsKey('IdentityKey')) { + throw "EnsureAttributes requires With.IdentityKey." + } + + if (-not $with.ContainsKey('Attributes')) { + throw "EnsureAttributes requires With.Attributes." + } + + $attributes = $with.Attributes + if ($null -eq $attributes -or -not ($attributes -is [hashtable])) { + throw "EnsureAttributes requires With.Attributes to be a hashtable." + } + + if ($attributes.Count -eq 0) { + throw "EnsureAttributes requires With.Attributes to contain at least one attribute." + } + + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'Identity' } + + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + $provider = $Context.Providers[$providerAlias] + + # Check if provider has EnsureAttributes method (fast path) + $hasEnsureAttributes = $null -ne $provider.PSObject.Methods['EnsureAttributes'] + + $anyChanged = $false + $attributeResults = @() + + if ($hasEnsureAttributes) { + # Fast path: call EnsureAttributes once + try { + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureAttributes' ` + -MethodArguments @([string]$with.IdentityKey, $attributes) + + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $anyChanged = [bool]$result.Changed + } + + # If provider returns per-attribute details, use them + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Attributes')) { + $attributeResults = $result.Attributes + } else { + # Otherwise, create a simple summary + foreach ($key in $attributes.Keys) { + $attributeResults += @{ + Name = $key + Changed = $anyChanged + Error = $null + } + } + } + } + catch { + throw + } + } + else { + # Fallback: call EnsureAttribute for each attribute + foreach ($key in $attributes.Keys) { + $attrName = [string]$key + $attrValue = $attributes[$key] + + try { + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureAttribute' ` + -MethodArguments @([string]$with.IdentityKey, $attrName, $attrValue) + + $changed = $false + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $changed = [bool]$result.Changed + } + + if ($changed) { + $anyChanged = $true + } + + $attributeResults += @{ + Name = $attrName + Changed = $changed + Error = $null + } + } + catch { + $attributeResults += @{ + Name = $attrName + Changed = $false + Error = $_.Exception.Message + } + throw + } + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Changed = $anyChanged + Error = $null + Data = @{ + Attributes = $attributeResults + } + } +} diff --git a/tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 b/tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 new file mode 100644 index 00000000..db7f3340 --- /dev/null +++ b/tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 @@ -0,0 +1,204 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Invoke-IdleStepEnsureAttribute (compatibility wrapper)' { + BeforeEach { + # Create a fake provider with EnsureAttribute support + $script:FakeProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.FakeIdentity' + CallLog = @() + } + + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } + + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param( + [Parameter(Mandatory)] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + $Value, + + [Parameter()] + [object] $AuthSession + ) + + $this.CallLog += @{ + Method = 'EnsureAttribute' + IdentityKey = $IdentityKey + Name = $Name + Value = $Value + AuthSession = $AuthSession + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Name = $Name + Changed = $true + } + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ Identity = $script:FakeProvider } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return [pscustomobject]@{ + SessionName = $Name + Options = $Options + Token = 'fake-auth-token' + } + } + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + Provider = 'Identity' + IdentityKey = 'user@contoso.com' + Name = 'Department' + Value = 'IT' + } + } + } + + Context 'Backward compatibility' { + It 'delegates to plural handler with single attribute' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + $script:FakeProvider.CallLog[0].Method | Should -Be 'EnsureAttribute' + $script:FakeProvider.CallLog[0].Name | Should -Be 'Department' + $script:FakeProvider.CallLog[0].Value | Should -Be 'IT' + } + + It 'preserves original step type in result' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Type | Should -Be 'IdLE.Step.EnsureAttribute' + } + + It 'returns StepResult with correct shape' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' + $result.Name | Should -Be 'Ensure Department' + $result.Status | Should -Be 'Completed' + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'Error' + $result.Error | Should -BeNullOrEmpty + } + + It 'preserves Changed flag from provider' { + # Override provider to return Changed=false + $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + return [pscustomobject]@{ + Changed = $false + } + } -Force + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Changed | Should -Be $false + } + } + + Context 'Validation (singular syntax)' { + It 'throws when With.IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' + } + + It 'throws when With.Name is missing' { + $step = $script:StepTemplate + $step.With.Remove('Name') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Name*' + } + + It 'throws when With.Value is missing' { + $step = $script:StepTemplate + $step.With.Remove('Value') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Value*' + } + + It 'throws when With is not a hashtable' { + $step = [pscustomobject]@{ + Name = 'Test' + Type = 'IdLE.Step.EnsureAttribute' + With = 'not-a-hashtable' + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires*With*to be a hashtable*' + } + } + + Context 'Authentication support' { + It 'passes auth session when AuthSessionName is provided' { + $step = $script:StepTemplate + $step.With.AuthSessionName = 'MicrosoftGraph' + $step.With.AuthSessionOptions = @{ Role = 'Admin' } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty + $script:FakeProvider.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' + } + } + + Context 'Provider selection' { + It 'uses default provider alias "Identity" when not specified' { + $step = $script:StepTemplate + $step.With.Remove('Provider') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + } + + It 'supports custom provider alias' { + $script:Context.Providers['CustomAD'] = $script:FakeProvider + $step = $script:StepTemplate + $step.With.Provider = 'CustomAD' + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProvider.CallLog.Count | Should -Be 1 + } + } +} diff --git a/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 b/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 new file mode 100644 index 00000000..50ec784d --- /dev/null +++ b/tests/Steps/Invoke-IdleStepEnsureAttributes.Tests.ps1 @@ -0,0 +1,368 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Invoke-IdleStepEnsureAttributes (built-in step)' { + BeforeEach { + # Create a fake provider with EnsureAttribute support (no EnsureAttributes) + $script:FakeProviderLegacy = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.FakeLegacy' + CallLog = @() + } + + $script:FakeProviderLegacy | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } + + $script:FakeProviderLegacy | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param( + [Parameter(Mandatory)] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + $Value, + + [Parameter()] + [object] $AuthSession + ) + + $this.CallLog += @{ + Method = 'EnsureAttribute' + IdentityKey = $IdentityKey + Name = $Name + Value = $Value + AuthSession = $AuthSession + } + + # Simulate change for specific attributes + $changed = ($Name -eq 'Department' -or $Name -eq 'Title') + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttribute' + IdentityKey = $IdentityKey + Name = $Name + Changed = $changed + } + } + + # Create a fake provider with EnsureAttributes support (fast path) + $script:FakeProviderOptimized = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.FakeOptimized' + CallLog = @() + } + + $script:FakeProviderOptimized | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } + + $script:FakeProviderOptimized | Add-Member -MemberType ScriptMethod -Name EnsureAttributes -Value { + param( + [Parameter(Mandatory)] + [string] $IdentityKey, + + [Parameter(Mandatory)] + [hashtable] $Attributes, + + [Parameter()] + [object] $AuthSession + ) + + $this.CallLog += @{ + Method = 'EnsureAttributes' + IdentityKey = $IdentityKey + Attributes = $Attributes + AuthSession = $AuthSession + } + + # Simulate some changes + $attributeResults = @() + $anyChanged = $false + foreach ($key in $Attributes.Keys) { + $changed = ($key -eq 'Department' -or $key -eq 'Title') + if ($changed) { $anyChanged = $true } + + $attributeResults += @{ + Name = $key + Changed = $changed + Error = $null + } + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureAttributes' + IdentityKey = $IdentityKey + Changed = $anyChanged + Attributes = $attributeResults + } + } + + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ Identity = $script:FakeProviderLegacy } + EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } + } + + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return [pscustomobject]@{ + SessionName = $Name + Options = $Options + Token = 'fake-auth-token' + } + } + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Ensure multiple attributes' + Type = 'IdLE.Step.EnsureAttributes' + With = @{ + Provider = 'Identity' + IdentityKey = 'user@contoso.com' + Attributes = @{ + Department = 'IT' + Title = 'Engineer' + Office = 'Building A' + } + } + } + } + + Context 'Validation' { + It 'throws when With is missing' { + $step = [pscustomobject]@{ + Name = 'Test' + Type = 'IdLE.Step.EnsureAttributes' + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires*With*to be a hashtable*' + } + + It 'throws when With is not a hashtable' { + $step = [pscustomobject]@{ + Name = 'Test' + Type = 'IdLE.Step.EnsureAttributes' + With = 'not-a-hashtable' + } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires*With*to be a hashtable*' + } + + It 'throws when With.IdentityKey is missing' { + $step = $script:StepTemplate + $step.With.Remove('IdentityKey') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' + } + + It 'throws when With.Attributes is missing' { + $step = $script:StepTemplate + $step.With.Remove('Attributes') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes*' + } + + It 'throws when With.Attributes is not a hashtable' { + $step = $script:StepTemplate + $step.With.Attributes = 'not-a-hashtable' + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes to be a hashtable*' + } + + It 'throws when With.Attributes is empty' { + $step = $script:StepTemplate + $step.With.Attributes = @{} + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Attributes to contain at least one attribute*' + } + + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw '*Provider*was not supplied*' + } + } + + Context 'Provider fast path (EnsureAttributes method)' { + BeforeEach { + $script:Context.Providers['Identity'] = $script:FakeProviderOptimized + } + + It 'calls EnsureAttributes method once when available' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $script:FakeProviderOptimized.CallLog.Count | Should -Be 1 + $script:FakeProviderOptimized.CallLog[0].Method | Should -Be 'EnsureAttributes' + $script:FakeProviderOptimized.CallLog[0].IdentityKey | Should -Be 'user@contoso.com' + $script:FakeProviderOptimized.CallLog[0].Attributes.Count | Should -Be 3 + $script:FakeProviderOptimized.CallLog[0].Attributes['Department'] | Should -Be 'IT' + $script:FakeProviderOptimized.CallLog[0].Attributes['Title'] | Should -Be 'Engineer' + $script:FakeProviderOptimized.CallLog[0].Attributes['Office'] | Should -Be 'Building A' + } + + It 'returns Changed=true when provider reports changes' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Changed | Should -Be $true + } + + It 'includes per-attribute results from provider' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Data | Should -Not -BeNullOrEmpty + $result.Data.Attributes | Should -Not -BeNullOrEmpty + $result.Data.Attributes.Count | Should -Be 3 + } + + It 'passes auth session when AuthSessionName is provided' { + $step = $script:StepTemplate + $step.With.AuthSessionName = 'MicrosoftGraph' + $step.With.AuthSessionOptions = @{ Role = 'Admin' } + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $step + + $script:FakeProviderOptimized.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty + $script:FakeProviderOptimized.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' + } + } + + Context 'Provider fallback (multiple EnsureAttribute calls)' { + It 'calls EnsureAttribute for each attribute when EnsureAttributes not available' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $script:FakeProviderLegacy.CallLog.Count | Should -Be 3 + $script:FakeProviderLegacy.CallLog[0].Method | Should -Be 'EnsureAttribute' + $script:FakeProviderLegacy.CallLog[1].Method | Should -Be 'EnsureAttribute' + $script:FakeProviderLegacy.CallLog[2].Method | Should -Be 'EnsureAttribute' + } + + It 'passes correct IdentityKey to each EnsureAttribute call' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $script:FakeProviderLegacy.CallLog | ForEach-Object { + $_.IdentityKey | Should -Be 'user@contoso.com' + } + } + + It 'passes correct attribute name and value to each call' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $callsByName = @{} + $script:FakeProviderLegacy.CallLog | ForEach-Object { + $callsByName[$_.Name] = $_.Value + } + + $callsByName['Department'] | Should -Be 'IT' + $callsByName['Title'] | Should -Be 'Engineer' + $callsByName['Office'] | Should -Be 'Building A' + } + + It 'returns Changed=true when any attribute changed' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + # Department and Title return Changed=true in our mock + $result.Changed | Should -Be $true + } + + It 'returns Changed=false when no attributes changed' { + # Override provider to return no changes + $script:FakeProviderLegacy | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + + $this.CallLog += @{ + Method = 'EnsureAttribute' + IdentityKey = $IdentityKey + Name = $Name + Value = $Value + } + + return [pscustomobject]@{ + Changed = $false + } + } -Force + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Changed | Should -Be $false + } + + It 'includes per-attribute results in fallback mode' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result.Data.Attributes | Should -Not -BeNullOrEmpty + $result.Data.Attributes.Count | Should -Be 3 + + # Check that all attributes have result entries + $attributeNames = $result.Data.Attributes | ForEach-Object { $_.Name } + $attributeNames | Should -Contain 'Department' + $attributeNames | Should -Contain 'Title' + $attributeNames | Should -Contain 'Office' + } + } + + Context 'StepResult shape' { + It 'returns StepResult with correct type and properties' { + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $script:StepTemplate + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' + $result.Name | Should -Be 'Ensure multiple attributes' + $result.Type | Should -Be 'IdLE.Step.EnsureAttributes' + $result.Status | Should -Be 'Completed' + $result.PSObject.Properties.Name | Should -Contain 'Changed' + $result.PSObject.Properties.Name | Should -Contain 'Error' + $result.PSObject.Properties.Name | Should -Contain 'Data' + $result.Error | Should -BeNullOrEmpty + } + } + + Context 'Default provider alias' { + It 'uses "Identity" as default provider when not specified' { + $step = $script:StepTemplate + $step.With.Remove('Provider') + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProviderLegacy.CallLog.Count | Should -BeGreaterThan 0 + } + + It 'supports custom provider alias' { + $script:Context.Providers['CustomAD'] = $script:FakeProviderLegacy + $step = $script:StepTemplate + $step.With.Provider = 'CustomAD' + + $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttributes' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $script:FakeProviderLegacy.CallLog.Count | Should -BeGreaterThan 0 + } + } +} From cc36ab37dcfa85d8b7d0a44e931dddb780062a07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:51:30 +0000 Subject: [PATCH 03/11] Update documentation and fix test conflicts for EnsureAttributes Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/steps.md | 1 + docs/reference/steps/step-ensure-attribute.md | 13 ++-- .../reference/steps/step-ensure-attributes.md | 66 +++++++++++++++++++ .../mock/joiner-minimal-ensureattributes.psd1 | 36 ++++++++++ tests/Core/Invoke-IdlePlan.Tests.ps1 | 11 +++- tests/Core/New-IdlePlan.Tests.ps1 | 12 +++- 6 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 docs/reference/steps/step-ensure-attributes.md create mode 100644 examples/workflows/mock/joiner-minimal-ensureattributes.psd1 diff --git a/docs/reference/steps.md b/docs/reference/steps.md index f9e461c1..f5694f51 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -11,6 +11,7 @@ | [IdLE.Step.EmitEvent](steps/step-emit-event.md) | ``IdLE.Steps.Common`` | Emits a custom event (demo step). | | [IdLE.Step.EnableIdentity](steps/step-enable-identity.md) | ``IdLE.Steps.Common`` | Enables an identity in the target system. | | [IdLE.Step.EnsureAttribute](steps/step-ensure-attribute.md) | ``IdLE.Steps.Common`` | Ensures that an identity attribute matches the desired value. | +| [IdLE.Step.EnsureAttributes](steps/step-ensure-attributes.md) | ``IdLE.Steps.Common`` | Ensures that multiple identity attributes match their desired values. | | [IdLE.Step.EnsureEntitlement](steps/step-ensure-entitlement.md) | ``IdLE.Steps.Common`` | Ensures that an entitlement assignment is present or absent for an identity. | | [IdLE.Step.Mailbox.EnsureOutOfOffice](steps/step-mailbox-ensure-out-of-office.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. | | [IdLE.Step.Mailbox.EnsureType](steps/step-mailbox-ensure-type.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). | diff --git a/docs/reference/steps/step-ensure-attribute.md b/docs/reference/steps/step-ensure-attribute.md index 5c6aa2d7..99f4575b 100644 --- a/docs/reference/steps/step-ensure-attribute.md +++ b/docs/reference/steps/step-ensure-attribute.md @@ -16,10 +16,15 @@ Ensures that an identity attribute matches the desired value. ## Description -The host must supply a provider instance via -Context.Providers[<ProviderAlias>]. The provider must implement an EnsureAttribute -method with the signature (IdentityKey, Name, Value) and return an object that -contains a boolean property 'Changed'. +[DEPRECATED] This step type is deprecated. Use IdLE.Step.EnsureAttributes instead. + +This is a compatibility wrapper that delegates to Invoke-IdleStepEnsureAttributes. +It converts the singular With.Name/With.Value syntax to the plural With.Attributes +hashtable format. + +The host must supply a provider instance via Context.Providers[<ProviderAlias>]. +The provider must implement an EnsureAttribute method with the signature +(IdentityKey, Name, Value) and return an object that contains a boolean property 'Changed'. The step is idempotent by design: it converges state to the desired value. diff --git a/docs/reference/steps/step-ensure-attributes.md b/docs/reference/steps/step-ensure-attributes.md new file mode 100644 index 00000000..21107318 --- /dev/null +++ b/docs/reference/steps/step-ensure-attributes.md @@ -0,0 +1,66 @@ +# IdLE.Step.EnsureAttributes + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.EnsureAttributes` +- **Module**: `IdLE.Steps.Common` +- **Implementation**: `Invoke-IdleStepEnsureAttributes` +- **Idempotent**: `Yes` + +## Synopsis + +Ensures that multiple identity attributes match their desired values. + +## Description + +This is a provider-agnostic step that can ensure multiple attributes in a single step. +The host must supply a provider instance via Context.Providers[<ProviderAlias>]. + +Provider interaction strategy: + +1. If the provider implements EnsureAttributes(IdentityKey, AttributesHashtable), it is called once (fast path). + +2. Otherwise, the step falls back to calling EnsureAttribute(IdentityKey, Name, Value) for each attribute. + +The step is idempotent by design: it converges state to the desired values. + +Authentication: + +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Tier0' \}). + +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `Attributes` | Yes | Hashtable of attributes to set | +| `IdentityKey` | Yes | Unique identifier for the identity | + +## Example + +```powershell +@{ + Name = 'IdLE.Step.EnsureAttributes Example' + Type = 'IdLE.Step.EnsureAttributes' + With = @{ + Attributes = @{ GivenName = 'First'; Surname = 'Last' } + IdentityKey = 'user.name' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations diff --git a/examples/workflows/mock/joiner-minimal-ensureattributes.psd1 b/examples/workflows/mock/joiner-minimal-ensureattributes.psd1 new file mode 100644 index 00000000..61f556a3 --- /dev/null +++ b/examples/workflows/mock/joiner-minimal-ensureattributes.psd1 @@ -0,0 +1,36 @@ +@{ + Name = 'Joiner - Minimal (EnsureAttributes)' + LifecycleEvent = 'Joiner' + + Steps = @( + @{ + Name = 'Emit start' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Joiner workflow started (minimalpack).' + } + } + + @{ + Name = 'Ensure user attributes' + Type = 'IdLE.Step.EnsureAttributes' + With = @{ + Provider = 'Identity' + IdentityKey = 'user1' + Attributes = @{ + Department = 'IT' + Title = 'Engineer' + Office = 'Building A' + } + } + } + + @{ + Name = 'Emit done' + Type = 'IdLE.Step.EmitEvent' + With = @{ + Message = 'Joiner workflow completed (minimalpack).' + } + } + ) +} diff --git a/tests/Core/Invoke-IdlePlan.Tests.ps1 b/tests/Core/Invoke-IdlePlan.Tests.ps1 index 17bef20b..6d7c0ff8 100644 --- a/tests/Core/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.Tests.ps1 @@ -163,12 +163,21 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + # Create a dummy provider with the required capability for EnsureAttributes + $dummyProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.TestDummy' + } + $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } + $providers = @{ + Identity = $dummyProvider StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.EnsureAttributes') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index bc0c7b2a..143c27f5 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -19,13 +19,23 @@ Describe 'New-IdlePlan' { '@ $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + # Create a dummy provider with the required capability for EnsureAttributes + $dummyProvider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.TestDummy' + } + $dummyProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Identity.Attribute.Ensure') + } + $providers = @{ Dummy = $true + Identity = $dummyProvider StepRegistry = @{ 'IdLE.Step.ResolveIdentity' = 'Invoke-IdleTestNoopStep' 'IdLE.Step.EnsureAttributes' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity', 'IdLE.Step.EnsureAttributes') + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ResolveIdentity') } $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers From 06b3b3a9deebc26047317b6b37e7e754fcd5f138 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:41:45 +0000 Subject: [PATCH 04/11] Update workflow templates to use EnsureAttributes with hashtable format Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/steps/step-ensure-attribute.md | 69 ------ .../workflows/joiner-with-retry-profiles.psd1 | 7 +- .../mock/joiner-ensureentitlement.psd1 | 4 +- .../mock/joiner-minimal-ensureattribute.psd1 | 9 +- .../workflows/mock/joiner-with-onfailure.psd1 | 7 +- .../templates/ad-joiner-complete.psd1 | 20 +- .../templates/ad-leaver-offboarding.psd1 | 7 +- .../templates/ad-mover-department-change.psd1 | 20 +- .../complete-leaver-entraid-exo.psd1 | 7 +- .../templates/entraid-joiner-complete.psd1 | 7 +- .../templates/entraid-leaver-offboarding.psd1 | 21 +- .../entraid-mover-department-change.psd1 | 28 +-- .../Private/Get-IdleStepRegistry.ps1 | 7 - src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 | 1 - src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 | 1 - .../Public/Get-IdleStepMetadataCatalog.ps1 | 6 - .../Public/Invoke-IdleStepEnsureAttribute.ps1 | 82 ------- .../Invoke-IdleStepEnsureAttribute.Tests.ps1 | 204 ------------------ 18 files changed, 61 insertions(+), 446 deletions(-) delete mode 100644 docs/reference/steps/step-ensure-attribute.md delete mode 100644 src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 delete mode 100644 tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 diff --git a/docs/reference/steps/step-ensure-attribute.md b/docs/reference/steps/step-ensure-attribute.md deleted file mode 100644 index 99f4575b..00000000 --- a/docs/reference/steps/step-ensure-attribute.md +++ /dev/null @@ -1,69 +0,0 @@ -# IdLE.Step.EnsureAttribute - -> Generated file. Do not edit by hand. -> Source: tools/Generate-IdleStepReference.ps1 - -## Summary - -- **Step Type**: `IdLE.Step.EnsureAttribute` -- **Module**: `IdLE.Steps.Common` -- **Implementation**: `Invoke-IdleStepEnsureAttribute` -- **Idempotent**: `Yes` - -## Synopsis - -Ensures that an identity attribute matches the desired value. - -## Description - -[DEPRECATED] This step type is deprecated. Use IdLE.Step.EnsureAttributes instead. - -This is a compatibility wrapper that delegates to Invoke-IdleStepEnsureAttributes. -It converts the singular With.Name/With.Value syntax to the plural With.Attributes -hashtable format. - -The host must supply a provider instance via Context.Providers[<ProviderAlias>]. -The provider must implement an EnsureAttribute method with the signature -(IdentityKey, Name, Value) and return an object that contains a boolean property 'Changed'. - -The step is idempotent by design: it converges state to the desired value. - -Authentication: - -- If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - -- With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @\{ Role = 'Tier0' \}). - -- ScriptBlocks in AuthSessionOptions are rejected (security boundary). - -## Inputs (With.*) - -The following keys are required in the step's ``With`` configuration: - -| Key | Required | Description | -| --- | --- | --- | -| `IdentityKey` | Yes | Unique identifier for the identity | -| `Name` | Yes | Name of the attribute or property | -| `Value` | Yes | Desired value to set | - -## Example - -```powershell -@{ - Name = 'IdLE.Step.EnsureAttribute Example' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ - IdentityKey = 'user.name' - Name = 'AttributeName' - Value = 'AttributeValue' - } -} -``` - -## See Also - -- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities -- [Providers](../providers.md) - Available provider implementations diff --git a/examples/workflows/joiner-with-retry-profiles.psd1 b/examples/workflows/joiner-with-retry-profiles.psd1 index 1e7d80a5..25ed8469 100644 --- a/examples/workflows/joiner-with-retry-profiles.psd1 +++ b/examples/workflows/joiner-with-retry-profiles.psd1 @@ -55,12 +55,13 @@ @{ Name = 'Set manager attribute' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Description = 'Set manager reference in Entra ID' RetryProfile = 'GraphAPI' With = @{ - AttributeName = 'manager' - Value = '{{Request.Data.ManagerId}}' + Attributes = @{ + manager = '{{Request.Data.ManagerId}}' + } } } ) diff --git a/examples/workflows/mock/joiner-ensureentitlement.psd1 b/examples/workflows/mock/joiner-ensureentitlement.psd1 index c1c7f8e2..36afd4c6 100644 --- a/examples/workflows/mock/joiner-ensureentitlement.psd1 +++ b/examples/workflows/mock/joiner-ensureentitlement.psd1 @@ -4,8 +4,8 @@ Steps = @( @{ Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ IdentityKey = 'user1'; Name = 'Department'; Value = 'IT'; Provider = 'Identity' } + Type = 'IdLE.Step.EnsureAttributes' + With = @{ IdentityKey = 'user1'; Attributes = @{ Department = 'IT' }; Provider = 'Identity' } }, @{ Name = 'Assign demo group' diff --git a/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 b/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 index 38e56638..67bb7e61 100644 --- a/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 +++ b/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 @@ -1,5 +1,5 @@ @{ - Name = 'Joiner - Minimal (EnsureAttribute)' + Name = 'Joiner - Minimal (EnsureAttributes)' LifecycleEvent = 'Joiner' Steps = @( @@ -13,12 +13,13 @@ @{ Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ Provider = 'Identity' IdentityKey = 'user1' - Name = 'Department' - Value = 'IT' + Attributes = @{ + Department = 'IT' + } } } diff --git a/examples/workflows/mock/joiner-with-onfailure.psd1 b/examples/workflows/mock/joiner-with-onfailure.psd1 index 4cf8c4a0..1c2c5777 100644 --- a/examples/workflows/mock/joiner-with-onfailure.psd1 +++ b/examples/workflows/mock/joiner-with-onfailure.psd1 @@ -11,11 +11,12 @@ } @{ Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'user1' - Name = 'Department' - Value = 'IT' + Attributes = @{ + Department = 'IT' + } Provider = 'Identity' } } diff --git a/examples/workflows/templates/ad-joiner-complete.psd1 b/examples/workflows/templates/ad-joiner-complete.psd1 index 231fe447..ceff16ec 100644 --- a/examples/workflows/templates/ad-joiner-complete.psd1 +++ b/examples/workflows/templates/ad-joiner-complete.psd1 @@ -23,22 +23,14 @@ } }, @{ - Name = 'Set Department' - Type = 'IdLE.Step.EnsureAttribute' + Name = 'Set Department and Title' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'newuser@contoso.local' - Name = 'Department' - Value = 'IT' - Provider = 'Identity' - } - }, - @{ - Name = 'Set Title' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ - IdentityKey = 'newuser@contoso.local' - Name = 'Title' - Value = 'Software Engineer' + Attributes = @{ + Department = 'IT' + Title = 'Software Engineer' + } Provider = 'Identity' } }, diff --git a/examples/workflows/templates/ad-leaver-offboarding.psd1 b/examples/workflows/templates/ad-leaver-offboarding.psd1 index 4496ee04..f8f1f489 100644 --- a/examples/workflows/templates/ad-leaver-offboarding.psd1 +++ b/examples/workflows/templates/ad-leaver-offboarding.psd1 @@ -14,11 +14,12 @@ }, @{ Name = 'Update Description with termination date' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'leavinguser@contoso.local' - Name = 'Description' - Value = 'Terminated 2026-01-18' + Attributes = @{ + Description = 'Terminated 2026-01-18' + } Provider = 'Identity' } }, diff --git a/examples/workflows/templates/ad-mover-department-change.psd1 b/examples/workflows/templates/ad-mover-department-change.psd1 index 63b5e318..749af356 100644 --- a/examples/workflows/templates/ad-mover-department-change.psd1 +++ b/examples/workflows/templates/ad-mover-department-change.psd1 @@ -3,27 +3,19 @@ LifecycleEvent = 'Mover' Steps = @( @{ - Name = 'Update Department' - Type = 'IdLE.Step.EnsureAttribute' + Name = 'Update Department and Title' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'existinguser@contoso.local' - Name = 'Department' - Value = 'Sales' + Attributes = @{ + Department = 'Sales' + Title = 'Sales Manager' + } # Provider alias - can be customized when host creates the provider hashtable. # Examples: 'Identity', 'SourceAD', 'TargetAD', 'SystemX', etc. Provider = 'Identity' } }, - @{ - Name = 'Update Title' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ - IdentityKey = 'existinguser@contoso.local' - Name = 'Title' - Value = 'Sales Manager' - Provider = 'Identity' - } - }, @{ Name = 'Revoke old IT department group' Type = 'IdLE.Step.EnsureEntitlement' diff --git a/examples/workflows/templates/complete-leaver-entraid-exo.psd1 b/examples/workflows/templates/complete-leaver-entraid-exo.psd1 index c7c42f72..23cee81e 100644 --- a/examples/workflows/templates/complete-leaver-entraid-exo.psd1 +++ b/examples/workflows/templates/complete-leaver-entraid-exo.psd1 @@ -47,14 +47,15 @@ } @{ Name = 'ClearManager' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } - Name = 'Manager' - Value = $null + Attributes = @{ + Manager = $null + } } } @{ diff --git a/examples/workflows/templates/entraid-joiner-complete.psd1 b/examples/workflows/templates/entraid-joiner-complete.psd1 index 757b4e19..48b7847e 100644 --- a/examples/workflows/templates/entraid-joiner-complete.psd1 +++ b/examples/workflows/templates/entraid-joiner-complete.psd1 @@ -49,7 +49,7 @@ } @{ Name = 'SetManagerAttribute' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Condition = @{ All = @( @{ @@ -61,8 +61,9 @@ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.Input.UserPrincipalName}}' - Name = 'Manager' - Value = '{{Request.Input.ManagerId}}' + Attributes = @{ + Manager = '{{Request.Input.ManagerId}}' + } } } @{ diff --git a/examples/workflows/templates/entraid-leaver-offboarding.psd1 b/examples/workflows/templates/entraid-leaver-offboarding.psd1 index 86a633d2..a9aa4e38 100644 --- a/examples/workflows/templates/entraid-leaver-offboarding.psd1 +++ b/examples/workflows/templates/entraid-leaver-offboarding.psd1 @@ -14,25 +14,16 @@ } } @{ - Name = 'ClearManager' - Type = 'IdLE.Step.EnsureAttribute' + Name = 'ClearManagerAndUpdateDisplayName' + Type = 'IdLE.Step.EnsureAttributes' With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.Input.UserObjectId}}' - Name = 'Manager' - Value = $null - } - } - @{ - Name = 'UpdateDisplayNameWithLeaver' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ - AuthSessionName = 'MicrosoftGraph' - AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserObjectId}}' - Name = 'DisplayName' - Value = '{{Request.Input.DisplayName}} (LEAVER)' + Attributes = @{ + Manager = $null + DisplayName = '{{Request.Input.DisplayName}} (LEAVER)' + } } } @{ diff --git a/examples/workflows/templates/entraid-mover-department-change.psd1 b/examples/workflows/templates/entraid-mover-department-change.psd1 index 004a3408..1acde4a3 100644 --- a/examples/workflows/templates/entraid-mover-department-change.psd1 +++ b/examples/workflows/templates/entraid-mover-department-change.psd1 @@ -5,18 +5,19 @@ Steps = @( @{ Name = 'UpdateDepartmentAttributes' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.Input.UserObjectId}}' - Name = 'Department' - Value = '{{Request.Input.NewDepartment}}' + Attributes = @{ + Department = '{{Request.Input.NewDepartment}}' + } } } @{ Name = 'UpdateJobTitle' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Condition = @{ All = @( @{ @@ -28,13 +29,14 @@ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.Input.UserObjectId}}' - Name = 'JobTitle' - Value = '{{Request.Input.NewJobTitle}}' + Attributes = @{ + JobTitle = '{{Request.Input.NewJobTitle}}' + } } } @{ Name = 'UpdateOfficeLocation' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Condition = @{ All = @( @{ @@ -46,8 +48,9 @@ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.Input.UserObjectId}}' - Name = 'OfficeLocation' - Value = '{{Request.Input.NewOfficeLocation}}' + Attributes = @{ + OfficeLocation = '{{Request.Input.NewOfficeLocation}}' + } } } @{ @@ -75,7 +78,7 @@ } @{ Name = 'UpdateManager' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Condition = @{ All = @( @{ @@ -87,8 +90,9 @@ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } IdentityKey = '{{Request.Input.UserObjectId}}' - Name = 'Manager' - Value = '{{Request.Input.NewManagerId}}' + Attributes = @{ + Manager = '{{Request.Input.NewManagerId}}' + } } } @{ diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 81f0a0b0..9e7952fc 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -114,13 +114,6 @@ function Get-IdleStepRegistry { } } - if (-not $registry.ContainsKey('IdLE.Step.EnsureAttribute')) { - $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureAttribute' -ModuleName 'IdLE.Steps.Common' - if (-not [string]::IsNullOrWhiteSpace($handler)) { - $registry['IdLE.Step.EnsureAttribute'] = $handler - } - } - if (-not $registry.ContainsKey('IdLE.Step.EnsureAttributes')) { $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureAttributes' -ModuleName 'IdLE.Steps.Common' if (-not [string]::IsNullOrWhiteSpace($handler)) { diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 index 53bf0b8c..53ed2607 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 @@ -13,7 +13,6 @@ FunctionsToExport = @( 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', - 'Invoke-IdleStepEnsureAttribute', 'Invoke-IdleStepEnsureAttributes', 'Invoke-IdleStepEnsureEntitlement', 'Invoke-IdleStepCreateIdentity', diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index a8ee172a..5437357c 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -47,7 +47,6 @@ if (Test-Path -Path $PublicPath) { Export-ModuleMember -Function @( 'Get-IdleStepMetadataCatalog', 'Invoke-IdleStepEmitEvent', - 'Invoke-IdleStepEnsureAttribute', 'Invoke-IdleStepEnsureAttributes', 'Invoke-IdleStepEnsureEntitlement', 'Invoke-IdleStepCreateIdentity', diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 5cafdf02..56d90c7b 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -53,12 +53,6 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Identity.Move') } - # IdLE.Step.EnsureAttribute - requires identity attribute ensure capability - # [DEPRECATED] Use IdLE.Step.EnsureAttributes instead - $catalog['IdLE.Step.EnsureAttribute'] = @{ - RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') - } - # IdLE.Step.EnsureAttributes - requires identity attribute ensure capability $catalog['IdLE.Step.EnsureAttributes'] = @{ RequiredCapabilities = @('IdLE.Identity.Attribute.Ensure') diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 deleted file mode 100644 index 5daa44dd..00000000 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 +++ /dev/null @@ -1,82 +0,0 @@ -function Invoke-IdleStepEnsureAttribute { - <# - .SYNOPSIS - Ensures that an identity attribute matches the desired value. - - .DESCRIPTION - [DEPRECATED] This step type is deprecated. Use IdLE.Step.EnsureAttributes instead. - - This is a compatibility wrapper that delegates to Invoke-IdleStepEnsureAttributes. - It converts the singular With.Name/With.Value syntax to the plural With.Attributes - hashtable format. - - The host must supply a provider instance via Context.Providers[]. - The provider must implement an EnsureAttribute method with the signature - (IdentityKey, Name, Value) and return an object that contains a boolean property 'Changed'. - - The step is idempotent by design: it converges state to the desired value. - - Authentication: - - If With.AuthSessionName is present, the step acquires an auth session via - Context.AcquireAuthSession(Name, Options) and passes it to the provider method - if the provider supports an AuthSession parameter. - - With.AuthSessionOptions (optional, hashtable) is passed to the broker for - session selection (e.g., @{ Role = 'Tier0' }). - - ScriptBlocks in AuthSessionOptions are rejected (security boundary). - - .PARAMETER Context - Execution context created by IdLE.Core. - - .PARAMETER Step - Normalized step object from the plan. Must contain a 'With' hashtable. - - .OUTPUTS - PSCustomObject (PSTypeName: IdLE.StepResult) - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Context, - - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step - ) - - $with = $Step.With - if ($null -eq $with -or -not ($with -is [hashtable])) { - throw "EnsureAttribute requires 'With' to be a hashtable." - } - - foreach ($key in @('IdentityKey', 'Name', 'Value')) { - if (-not $with.ContainsKey($key)) { - throw "EnsureAttribute requires With.$key." - } - } - - # Convert singular syntax to plural format - $attributeName = [string]$with.Name - $attributeValue = $with.Value - - $pluralWith = $with.Clone() - $pluralWith.Remove('Name') - $pluralWith.Remove('Value') - $pluralWith['Attributes'] = @{ - $attributeName = $attributeValue - } - - $pluralStep = [pscustomobject]@{ - Name = $Step.Name - Type = 'IdLE.Step.EnsureAttributes' - With = $pluralWith - } - - # Delegate to plural handler - $result = Invoke-IdleStepEnsureAttributes -Context $Context -Step $pluralStep - - # Preserve the original step type in the result - $result.Type = [string]$Step.Type - - return $result -} diff --git a/tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 b/tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 deleted file mode 100644 index db7f3340..00000000 --- a/tests/Steps/Invoke-IdleStepEnsureAttribute.Tests.ps1 +++ /dev/null @@ -1,204 +0,0 @@ -Set-StrictMode -Version Latest - -BeforeAll { - . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') - Import-IdleTestModule -} - -Describe 'Invoke-IdleStepEnsureAttribute (compatibility wrapper)' { - BeforeEach { - # Create a fake provider with EnsureAttribute support - $script:FakeProvider = [pscustomobject]@{ - PSTypeName = 'IdLE.Provider.FakeIdentity' - CallLog = @() - } - - $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { - return @('IdLE.Identity.Attribute.Ensure') - } - - $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param( - [Parameter(Mandatory)] - [string] $IdentityKey, - - [Parameter(Mandatory)] - [string] $Name, - - [Parameter(Mandatory)] - $Value, - - [Parameter()] - [object] $AuthSession - ) - - $this.CallLog += @{ - Method = 'EnsureAttribute' - IdentityKey = $IdentityKey - Name = $Name - Value = $Value - AuthSession = $AuthSession - } - - return [pscustomobject]@{ - PSTypeName = 'IdLE.ProviderResult' - Operation = 'EnsureAttribute' - IdentityKey = $IdentityKey - Name = $Name - Changed = $true - } - } - - $script:Context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Plan = $null - Providers = @{ Identity = $script:FakeProvider } - EventSink = [pscustomobject]@{ WriteEvent = { param($Type, $Message, $StepName, $Data) } } - } - - $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { - param($Name, $Options) - return [pscustomobject]@{ - SessionName = $Name - Options = $Options - Token = 'fake-auth-token' - } - } - - $script:StepTemplate = [pscustomobject]@{ - Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttribute' - With = @{ - Provider = 'Identity' - IdentityKey = 'user@contoso.com' - Name = 'Department' - Value = 'IT' - } - } - } - - Context 'Backward compatibility' { - It 'delegates to plural handler with single attribute' { - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog.Count | Should -Be 1 - $script:FakeProvider.CallLog[0].Method | Should -Be 'EnsureAttribute' - $script:FakeProvider.CallLog[0].Name | Should -Be 'Department' - $script:FakeProvider.CallLog[0].Value | Should -Be 'IT' - } - - It 'preserves original step type in result' { - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Type | Should -Be 'IdLE.Step.EnsureAttribute' - } - - It 'returns StepResult with correct shape' { - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result | Should -Not -BeNullOrEmpty - $result.PSObject.TypeNames[0] | Should -Be 'IdLE.StepResult' - $result.Name | Should -Be 'Ensure Department' - $result.Status | Should -Be 'Completed' - $result.PSObject.Properties.Name | Should -Contain 'Changed' - $result.PSObject.Properties.Name | Should -Contain 'Error' - $result.Error | Should -BeNullOrEmpty - } - - It 'preserves Changed flag from provider' { - # Override provider to return Changed=false - $script:FakeProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { - param($IdentityKey, $Name, $Value, $AuthSession) - return [pscustomobject]@{ - Changed = $false - } - } -Force - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $script:StepTemplate - - $result.Changed | Should -Be $false - } - } - - Context 'Validation (singular syntax)' { - It 'throws when With.IdentityKey is missing' { - $step = $script:StepTemplate - $step.With.Remove('IdentityKey') - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.IdentityKey*' - } - - It 'throws when With.Name is missing' { - $step = $script:StepTemplate - $step.With.Remove('Name') - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Name*' - } - - It 'throws when With.Value is missing' { - $step = $script:StepTemplate - $step.With.Remove('Value') - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires With.Value*' - } - - It 'throws when With is not a hashtable' { - $step = [pscustomobject]@{ - Name = 'Test' - Type = 'IdLE.Step.EnsureAttribute' - With = 'not-a-hashtable' - } - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - { & $handler -Context $script:Context -Step $step } | Should -Throw '*requires*With*to be a hashtable*' - } - } - - Context 'Authentication support' { - It 'passes auth session when AuthSessionName is provided' { - $step = $script:StepTemplate - $step.With.AuthSessionName = 'MicrosoftGraph' - $step.With.AuthSessionOptions = @{ Role = 'Admin' } - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog[0].AuthSession | Should -Not -BeNullOrEmpty - $script:FakeProvider.CallLog[0].AuthSession.SessionName | Should -Be 'MicrosoftGraph' - } - } - - Context 'Provider selection' { - It 'uses default provider alias "Identity" when not specified' { - $step = $script:StepTemplate - $step.With.Remove('Provider') - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog.Count | Should -Be 1 - } - - It 'supports custom provider alias' { - $script:Context.Providers['CustomAD'] = $script:FakeProvider - $step = $script:StepTemplate - $step.With.Provider = 'CustomAD' - - $handler = 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' - $result = & $handler -Context $script:Context -Step $step - - $result.Status | Should -Be 'Completed' - $script:FakeProvider.CallLog.Count | Should -Be 1 - } - } -} From 7614ee4347ff3b1afcbe105be68055636b0799ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:44:22 +0000 Subject: [PATCH 05/11] docs: Update step references from IdLE.Step.EnsureAttribute to IdLE.Step.EnsureAttributes with new Attributes hashtable syntax Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/capabilities.md | 2 +- docs/reference/providers/provider-ad.md | 32 +++++++++++-------- docs/reference/providers/provider-entraID.md | 6 ++-- docs/reference/providers/provider-mock.md | 9 +++--- docs/reference/steps.md | 1 - docs/use/installation.md | 2 +- tests/Packaging/ModuleSurface.Tests.ps1 | 4 +-- .../Invoke-IdleStepAuthSession.Tests.ps1 | 14 ++++---- 8 files changed, 37 insertions(+), 33 deletions(-) diff --git a/docs/reference/capabilities.md b/docs/reference/capabilities.md index 0226573f..92b04b60 100644 --- a/docs/reference/capabilities.md +++ b/docs/reference/capabilities.md @@ -114,7 +114,7 @@ Steps require capabilities, but **capabilities are not step names**. Examples of step type identifiers (not capabilities): -- `IdLE.Step.EnsureAttribute` +- `IdLE.Step.EnsureAttributes` - `IdLE.Step.DisableIdentity` If you need a mapping between step types and required capabilities, document that mapping next to the diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 0338bba3..08d099c1 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -322,24 +322,26 @@ In workflow definitions, steps specify which auth context to use via `AuthSessio ```powershell @{ - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Name = 'SetPrivilegedAttribute' With = @{ IdentityKey = 'user@domain.com' - Name = 'AdminCount' - Value = 1 + Attributes = @{ + AdminCount = 1 + } AuthSessionName = 'ActiveDirectory' AuthSessionOptions = @{ Role = 'Tier0' } # Broker returns Tier0 credential } } @{ - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' Name = 'SetDepartment' With = @{ IdentityKey = 'user@domain.com' - Name = 'Department' - Value = 'IT' + Attributes = @{ + Department = 'IT' + } AuthSessionName = 'ActiveDirectory' AuthSessionOptions = @{ Role = 'Admin' } # Broker returns Admin credential } @@ -529,7 +531,7 @@ The following built-in steps in `IdLE.Steps.Common` work with the AD provider: - **IdLE.Step.EnableIdentity** - Enable user accounts - **IdLE.Step.MoveIdentity** - Move users between OUs - **IdLE.Step.DeleteIdentity** - Delete user accounts (requires provider initialization with `-AllowDelete` switch) -- **IdLE.Step.EnsureAttribute** - Set/update user attributes +- **IdLE.Step.EnsureAttributes** - Set/update user attributes - **IdLE.Step.EnsureEntitlement** - Manage group memberships Step metadata (including required capabilities) is provided by step pack modules (`IdLE.Steps.Common`) and used for plan-time validation. @@ -662,17 +664,18 @@ The provider automatically detects the format and resolves it to the manager's D } ``` -**Setting Manager via EnsureAttribute (UPN):** +**Setting Manager via EnsureAttributes (UPN):** ```powershell @{ Name = 'SetManager' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ Provider = 'Identity' IdentityKey = 'jdoe' - Name = 'Manager' - Value = 'jsmith@contoso.local' # UPN - will be resolved to DN + Attributes = @{ + Manager = 'jsmith@contoso.local' # UPN - will be resolved to DN + } AuthSessionName = 'ActiveDirectory' } } @@ -685,12 +688,13 @@ To clear the Manager attribute, set the value to `$null`: ```powershell @{ Name = 'ClearManager' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ Provider = 'Identity' IdentityKey = 'jdoe' - Name = 'Manager' - Value = $null + Attributes = @{ + Manager = $null + } AuthSessionName = 'ActiveDirectory' } } diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 875b13fc..66577b99 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -316,7 +316,7 @@ This section lists the Microsoft Graph API permissions required for each step ty | `IdLE.Step.CreateIdentity` | `User.ReadWrite.All` | Requires write permissions to create users | | `IdLE.Step.DisableIdentity` | `User.ReadWrite.All` | Modifies `accountEnabled` property | | `IdLE.Step.EnableIdentity` | `User.ReadWrite.All` | Modifies `accountEnabled` property | -| `IdLE.Step.EnsureAttribute` | `User.ReadWrite.All` | Modifies user properties (displayName, department, etc.) | +| `IdLE.Step.EnsureAttributes` | `User.ReadWrite.All` | Modifies user properties (displayName, department, etc.) | | `IdLE.Step.DeleteIdentity` | `User.ReadWrite.All` | Requires `AllowDelete = $true` on provider | | `IdLE.Step.RevokeIdentitySessions` | `User.RevokeSessions.All` | Security-sensitive; invalidates all active sessions | | `IdLE.Step.EnsureEntitlement` | `Group.Read.All`
`GroupMember.ReadWrite.All` | Lists and modifies group memberships | @@ -511,7 +511,7 @@ Transient errors include metadata in the exception message: ### Identity Attributes -These attributes can be set via `CreateIdentity` and `EnsureAttribute`: +These attributes can be set via `CreateIdentity` and `EnsureAttributes`: | Attribute | Graph Property | Notes | |-----------|---------------|-------| @@ -609,7 +609,7 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers The provider works with these built-in IdLE steps: - `IdLE.Step.CreateIdentity` -- `IdLE.Step.EnsureAttribute` +- `IdLE.Step.EnsureAttributes` - `IdLE.Step.DisableIdentity` - `IdLE.Step.EnableIdentity` - `IdLE.Step.RevokeIdentitySessions` (revokes active sign-in sessions) diff --git a/docs/reference/providers/provider-mock.md b/docs/reference/providers/provider-mock.md index 4be3df23..f1dff856 100644 --- a/docs/reference/providers/provider-mock.md +++ b/docs/reference/providers/provider-mock.md @@ -115,7 +115,7 @@ This provider has no additional data-only option keys beyond its constructor par ### Idempotency and consistency - **Idempotent operations:** Partial - - `EnsureAttribute` is idempotent (returns `Changed = $false` when already converged). + - `EnsureAttributes` is idempotent (returns `Changed = $false` when already converged). - `DisableIdentity` is idempotent. - Entitlement grant/revoke are idempotent by Kind+Id. - `GetIdentity` creates missing identities on demand (test convenience). @@ -159,12 +159,13 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers Steps = @( @{ Name = 'Ensure department' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ Provider = 'Identity' IdentityKey = 'user1' - Name = 'Department' - Value = 'IT' + Attributes = @{ + Department = 'IT' + } } } ) diff --git a/docs/reference/steps.md b/docs/reference/steps.md index f5694f51..3f56767e 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -10,7 +10,6 @@ | [IdLE.Step.DisableIdentity](steps/step-disable-identity.md) | ``IdLE.Steps.Common`` | Disables an identity in the target system. | | [IdLE.Step.EmitEvent](steps/step-emit-event.md) | ``IdLE.Steps.Common`` | Emits a custom event (demo step). | | [IdLE.Step.EnableIdentity](steps/step-enable-identity.md) | ``IdLE.Steps.Common`` | Enables an identity in the target system. | -| [IdLE.Step.EnsureAttribute](steps/step-ensure-attribute.md) | ``IdLE.Steps.Common`` | Ensures that an identity attribute matches the desired value. | | [IdLE.Step.EnsureAttributes](steps/step-ensure-attributes.md) | ``IdLE.Steps.Common`` | Ensures that multiple identity attributes match their desired values. | | [IdLE.Step.EnsureEntitlement](steps/step-ensure-entitlement.md) | ``IdLE.Steps.Common`` | Ensures that an entitlement assignment is present or absent for an identity. | | [IdLE.Step.Mailbox.EnsureOutOfOffice](steps/step-mailbox-ensure-out-of-office.md) | ``IdLE.Steps.Mailbox`` | Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. | diff --git a/docs/use/installation.md b/docs/use/installation.md index 7c5bec28..3de95cc1 100644 --- a/docs/use/installation.md +++ b/docs/use/installation.md @@ -102,7 +102,7 @@ Get-Command -Module IdLE `IdLE` is the **baseline** entrypoint. It declares `IdLE.Core` and `IdLE.Steps.Common` as dependencies: - **IdLE.Core** — the workflow engine (step-agnostic) -- **IdLE.Steps.Common** — first-party built-in steps (e.g. `IdLE.Step.EmitEvent`, `IdLE.Step.EnsureAttribute`) +- **IdLE.Steps.Common** — first-party built-in steps (e.g. `IdLE.Step.EmitEvent`, `IdLE.Step.EnsureAttributes`) **PowerShell Gallery installation:** PowerShell automatically installs and imports these dependencies when you `Install-Module IdLE` and `Import-Module IdLE`. diff --git a/tests/Packaging/ModuleSurface.Tests.ps1 b/tests/Packaging/ModuleSurface.Tests.ps1 index 3ec10fab..ca440b0b 100644 --- a/tests/Packaging/ModuleSurface.Tests.ps1 +++ b/tests/Packaging/ModuleSurface.Tests.ps1 @@ -142,8 +142,8 @@ Describe 'Module manifests and public surface' { # Accept both unqualified (global export) and module-qualified (nested) formats $registry['IdLE.Step.EmitEvent'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEmitEvent$' - $registry.ContainsKey('IdLE.Step.EnsureAttribute') | Should -BeTrue - $registry['IdLE.Step.EnsureAttribute'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureAttribute$' + $registry.ContainsKey('IdLE.Step.EnsureAttributes') | Should -BeTrue + $registry['IdLE.Step.EnsureAttributes'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureAttributes$' $registry.ContainsKey('IdLE.Step.EnsureEntitlement') | Should -BeTrue $registry['IdLE.Step.EnsureEntitlement'] | Should -Match '^(IdLE\.Steps\.Common\\)?Invoke-IdleStepEnsureEntitlement$' diff --git a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 index 69d488be..5b9723dc 100644 --- a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 @@ -56,7 +56,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' @@ -117,7 +117,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' @@ -176,7 +176,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' @@ -239,7 +239,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' @@ -280,7 +280,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' @@ -341,7 +341,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' @@ -385,7 +385,7 @@ Describe 'IdLE.Steps - Auth Session Routing' { $step = [pscustomobject]@{ PSTypeName = 'IdLE.Step' Name = 'TestStep' - Type = 'IdLE.Step.EnsureAttribute' + Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' Name = 'Department' From 3fb60859b8a6788b4654a06e5e9f61b5f3b18617 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:48:04 +0000 Subject: [PATCH 06/11] Remove deprecated EnsureAttribute step and update all references to EnsureAttributes Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/Packaging/ModuleSurface.Tests.ps1 | 2 +- .../Invoke-IdleStepAuthSession.Tests.ps1 | 35 ++++++++----------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/Packaging/ModuleSurface.Tests.ps1 b/tests/Packaging/ModuleSurface.Tests.ps1 index ca440b0b..cf60039c 100644 --- a/tests/Packaging/ModuleSurface.Tests.ps1 +++ b/tests/Packaging/ModuleSurface.Tests.ps1 @@ -269,7 +269,7 @@ Describe 'Module manifests and public surface' { $exported = (Get-Command -Module IdLE.Steps.Common).Name $exported | Should -Contain 'Invoke-IdleStepEmitEvent' - $exported | Should -Contain 'Invoke-IdleStepEnsureAttribute' + $exported | Should -Contain 'Invoke-IdleStepEnsureAttributes' $exported | Should -Contain 'Invoke-IdleStepEnsureEntitlement' } diff --git a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 index 5b9723dc..ba81cf35 100644 --- a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1 @@ -59,15 +59,14 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } AuthSessionName = 'ActiveDirectory' AuthSessionOptions = @{ Role = 'Tier0' } } } # Act - $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step # Assert $result | Should -Not -BeNullOrEmpty @@ -120,13 +119,12 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } } } # Act - $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step # Assert $result | Should -Not -BeNullOrEmpty @@ -179,14 +177,13 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } AuthSessionName = 'ActiveDirectory' } } # Act - $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step # Assert $result | Should -Not -BeNullOrEmpty @@ -242,14 +239,13 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } AuthSessionName = 'ActiveDirectory' } } # Act - $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step # Assert $result | Should -Not -BeNullOrEmpty @@ -283,15 +279,14 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } AuthSessionName = 'ActiveDirectory' AuthSessionOptions = 'invalid-string' } } # Act & Assert - { Invoke-IdleStepEnsureAttribute -Context $context -Step $step } | + { Invoke-IdleStepEnsureAttributes -Context $context -Step $step } | Should -Throw '*AuthSessionOptions*hashtable*' } @@ -344,14 +339,13 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } # No AuthSessionName - should still try to acquire default session } } # Act - $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + $result = Invoke-IdleStepEnsureAttributes -Context $context -Step $step # Assert $result | Should -Not -BeNullOrEmpty @@ -388,14 +382,13 @@ Describe 'IdLE.Steps - Auth Session Routing' { Type = 'IdLE.Step.EnsureAttributes' With = @{ IdentityKey = 'testuser' - Name = 'Department' - Value = 'IT' + Attributes = @{ Department = 'IT' } AuthSessionName = 'ActiveDirectory' # Explicitly set but no broker } } # Act & Assert - { Invoke-IdleStepEnsureAttribute -Context $context -Step $step } | + { Invoke-IdleStepEnsureAttributes -Context $context -Step $step } | Should -Throw '*AuthSessionName*AcquireAuthSession*' } } From dbe7764c343ee140bae7abfacdeabcf350e8c03a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:45:06 +0000 Subject: [PATCH 07/11] docs: Remove AdminCount example from provider-ad.md, keep only Department example Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 08d099c1..75f8edd1 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -321,19 +321,6 @@ $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { In workflow definitions, steps specify which auth context to use via `AuthSessionOptions`: ```powershell -@{ - Type = 'IdLE.Step.EnsureAttributes' - Name = 'SetPrivilegedAttribute' - With = @{ - IdentityKey = 'user@domain.com' - Attributes = @{ - AdminCount = 1 - } - AuthSessionName = 'ActiveDirectory' - AuthSessionOptions = @{ Role = 'Tier0' } # Broker returns Tier0 credential - } -} - @{ Type = 'IdLE.Step.EnsureAttributes' Name = 'SetDepartment' From 6f3cd782c34a526483cd63d5427fa53ff9855a3b Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:28:16 +0100 Subject: [PATCH 08/11] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Invoke-IdleStepEnsureAttributes.ps1 | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 index 7ba00504..273b5b17 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 @@ -89,35 +89,30 @@ function Invoke-IdleStepEnsureAttributes { if ($hasEnsureAttributes) { # Fast path: call EnsureAttributes once - try { - $result = Invoke-IdleProviderMethod ` - -Context $Context ` - -With $with ` - -ProviderAlias $providerAlias ` - -MethodName 'EnsureAttributes' ` - -MethodArguments @([string]$with.IdentityKey, $attributes) - - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { - $anyChanged = [bool]$result.Changed - } - - # If provider returns per-attribute details, use them - if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Attributes')) { - $attributeResults = $result.Attributes - } else { - # Otherwise, create a simple summary - foreach ($key in $attributes.Keys) { - $attributeResults += @{ - Name = $key - Changed = $anyChanged - Error = $null - } + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureAttributes' ` + -MethodArguments @([string]$with.IdentityKey, $attributes) + + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { + $anyChanged = [bool]$result.Changed + } + + # If provider returns per-attribute details, use them + if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Attributes')) { + $attributeResults = $result.Attributes + } else { + # Otherwise, create a simple summary + foreach ($key in $attributes.Keys) { + $attributeResults += @{ + Name = $key + Changed = $anyChanged + Error = $null } } } - catch { - throw - } } else { # Fallback: call EnsureAttribute for each attribute @@ -149,11 +144,7 @@ function Invoke-IdleStepEnsureAttributes { } } catch { - $attributeResults += @{ - Name = $attrName - Changed = $false - Error = $_.Exception.Message - } + throw } } From ff19e4362e471d26dcf76b11b1dc39462410e0ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:31:56 +0000 Subject: [PATCH 09/11] Fix remaining legacy references and website build issue Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-mock.md | 4 +++- docs/use/quickstart.md | 2 +- examples/README.md | 4 +++- .../Public/Invoke-IdleStepEnsureAttributes.ps1 | 5 +++-- website/sidebars.js | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/reference/providers/provider-mock.md b/docs/reference/providers/provider-mock.md index f1dff856..6aaf989f 100644 --- a/docs/reference/providers/provider-mock.md +++ b/docs/reference/providers/provider-mock.md @@ -115,7 +115,9 @@ This provider has no additional data-only option keys beyond its constructor par ### Idempotency and consistency - **Idempotent operations:** Partial - - `EnsureAttributes` is idempotent (returns `Changed = $false` when already converged). + - `EnsureAttributes` step is idempotent (returns `Changed = $false` when already converged). + - The step calls the provider's `EnsureAttributes` method if available (batch operation). + - Otherwise, it falls back to calling `EnsureAttribute` for each attribute individually. - `DisableIdentity` is idempotent. - Entitlement grant/revoke are idempotent by Kind+Id. - `GetIdentity` creates missing identities on demand (test convenience). diff --git a/docs/use/quickstart.md b/docs/use/quickstart.md index 9e29882a..5b144245 100644 --- a/docs/use/quickstart.md +++ b/docs/use/quickstart.md @@ -105,7 +105,7 @@ The mock provider below can be used with workflows that use following Step Types - IdLE.Step.EmitEvent - IdLE.Step.ReadIdentity -- IdLE.Step.EnsureAttribute +- IdLE.Step.EnsureAttributes - IdLE.Step.DisableIdentity - IdLE.Step.EnableIdentity - IdLE.Step.EnsureEntitlement diff --git a/examples/README.md b/examples/README.md index 79a91f66..82dfa52e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,7 +13,8 @@ Workflows that run out-of-the-box with `IdLE.Provider.Mock`. These are fully fun **Workflows:** - `joiner-minimal.psd1` — minimal workflow with a single EmitEvent step -- `joiner-minimal-ensureattribute.psd1` — demonstrates EnsureAttribute step +- `joiner-minimal-ensureattribute.psd1` — demonstrates EnsureAttributes step with a single attribute +- `joiner-minimal-ensureattributes.psd1` — demonstrates EnsureAttributes step with multiple attributes - `joiner-ensureentitlement.psd1` — demonstrates EnsureEntitlement step for group assignment - `joiner-with-condition.psd1` — demonstrates conditional step execution - `joiner-with-onfailure.psd1` — demonstrates OnFailureSteps for cleanup and notifications @@ -127,6 +128,7 @@ Hosts can optionally stream events live by providing `-EventSink` as an object i |---------------|----------|--------------------|--------------------|------------------------| | joiner-minimal.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-minimal-ensureattribute.psd1 | Mock | ✅ Yes | Identity (Mock) | None | +| joiner-minimal-ensureattributes.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-ensureentitlement.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-with-condition.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-with-onfailure.psd1 | Mock | ✅ Yes | Identity (Mock) | None | diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 index 273b5b17..d89bf137 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 @@ -104,11 +104,12 @@ function Invoke-IdleStepEnsureAttributes { if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Attributes')) { $attributeResults = $result.Attributes } else { - # Otherwise, create a simple summary + # Provider doesn't return per-attribute details, so we can't determine individual attribute changes + # Report overall status but mark individual attribute change status as unknown foreach ($key in $attributes.Keys) { $attributeResults += @{ Name = $key - Changed = $anyChanged + Changed = $anyChanged # Overall result - individual changes unknown without provider details Error = $null } } diff --git a/website/sidebars.js b/website/sidebars.js index d29c613f..269b0085 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -85,7 +85,7 @@ const sidebars = { 'reference/steps/step-disable-identity', 'reference/steps/step-enable-identity', 'reference/steps/step-emit-event', - 'reference/steps/step-ensure-attribute', + 'reference/steps/step-ensure-attributes', 'reference/steps/step-ensure-entitlement', 'reference/steps/step-move-identity', 'reference/steps/step-trigger-directory-sync', From f34db9b798000c057d4b28bb6e83321029bd2001 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:51:58 +0000 Subject: [PATCH 10/11] Remove legacy ensureattribute file and fix provider installation docs Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 10 ++++-- examples/README.md | 2 -- .../mock/joiner-minimal-ensureattribute.psd1 | 34 ------------------- 3 files changed, 8 insertions(+), 38 deletions(-) delete mode 100644 examples/workflows/mock/joiner-minimal-ensureattribute.psd1 diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 75f8edd1..09aa7f42 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -119,12 +119,18 @@ Follow the principle of least privilege - grant only the permissions required fo ## Installation and Import -The AD provider is automatically imported when you import the main IdLE module: +The AD provider is a **standalone provider module** that must be imported separately: ```powershell -Import-Module IdLE +# Import the AD provider module +Import-Module .\src\IdLE.Provider.AD\IdLE.Provider.AD.psd1 + +# Or if installed from PowerShell Gallery: +Import-Module IdLE.Provider.AD ``` +**Note:** The AD provider requires `IdLE.Core` to be available. When using IdLE in development mode (from the repository), import the main `IdLE` module first, which automatically loads the required dependencies. When using published packages from PowerShell Gallery, module dependencies are resolved automatically. + This makes `New-IdleADIdentityProvider` available in your session. --- diff --git a/examples/README.md b/examples/README.md index 82dfa52e..3229a582 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,7 +13,6 @@ Workflows that run out-of-the-box with `IdLE.Provider.Mock`. These are fully fun **Workflows:** - `joiner-minimal.psd1` — minimal workflow with a single EmitEvent step -- `joiner-minimal-ensureattribute.psd1` — demonstrates EnsureAttributes step with a single attribute - `joiner-minimal-ensureattributes.psd1` — demonstrates EnsureAttributes step with multiple attributes - `joiner-ensureentitlement.psd1` — demonstrates EnsureEntitlement step for group assignment - `joiner-with-condition.psd1` — demonstrates conditional step execution @@ -127,7 +126,6 @@ Hosts can optionally stream events live by providing `-EventSink` as an object i | Workflow File | Category | Runnable with Mock | Required Providers | External Prerequisites | |---------------|----------|--------------------|--------------------|------------------------| | joiner-minimal.psd1 | Mock | ✅ Yes | Identity (Mock) | None | -| joiner-minimal-ensureattribute.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-minimal-ensureattributes.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-ensureentitlement.psd1 | Mock | ✅ Yes | Identity (Mock) | None | | joiner-with-condition.psd1 | Mock | ✅ Yes | Identity (Mock) | None | diff --git a/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 b/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 deleted file mode 100644 index 67bb7e61..00000000 --- a/examples/workflows/mock/joiner-minimal-ensureattribute.psd1 +++ /dev/null @@ -1,34 +0,0 @@ -@{ - Name = 'Joiner - Minimal (EnsureAttributes)' - LifecycleEvent = 'Joiner' - - Steps = @( - @{ - Name = 'Emit start' - Type = 'IdLE.Step.EmitEvent' - With = @{ - Message = 'Joiner workflow started (minimalpack).' - } - } - - @{ - Name = 'Ensure Department' - Type = 'IdLE.Step.EnsureAttributes' - With = @{ - Provider = 'Identity' - IdentityKey = 'user1' - Attributes = @{ - Department = 'IT' - } - } - } - - @{ - Name = 'Emit done' - Type = 'IdLE.Step.EmitEvent' - With = @{ - Message = 'Joiner workflow completed (minimalpack).' - } - } - ) -} From aa8a3f140bd39f63e3b3bb1c5a951be396b3c18e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:59:59 +0000 Subject: [PATCH 11/11] Simplify provider-ad.md import to name-based only Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 09aa7f42..ee26d9e8 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -122,14 +122,10 @@ Follow the principle of least privilege - grant only the permissions required fo The AD provider is a **standalone provider module** that must be imported separately: ```powershell -# Import the AD provider module -Import-Module .\src\IdLE.Provider.AD\IdLE.Provider.AD.psd1 - -# Or if installed from PowerShell Gallery: Import-Module IdLE.Provider.AD ``` -**Note:** The AD provider requires `IdLE.Core` to be available. When using IdLE in development mode (from the repository), import the main `IdLE` module first, which automatically loads the required dependencies. When using published packages from PowerShell Gallery, module dependencies are resolved automatically. +**Note:** The AD provider requires `IdLE.Core` to be available. When using IdLE in development mode (from the repository), import the main `IdLE` module first, which automatically loads the required dependencies and extends `PSModulePath` to make provider modules discoverable by name. When using published packages from PowerShell Gallery, module dependencies are resolved automatically. This makes `New-IdleADIdentityProvider` available in your session.