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..ee26d9e8 100644
--- a/docs/reference/providers/provider-ad.md
+++ b/docs/reference/providers/provider-ad.md
@@ -119,12 +119,14 @@ 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-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 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.
---
@@ -322,24 +324,13 @@ In workflow definitions, steps specify which auth context to use via `AuthSessio
```powershell
@{
- Type = 'IdLE.Step.EnsureAttribute'
- Name = 'SetPrivilegedAttribute'
- With = @{
- IdentityKey = 'user@domain.com'
- Name = 'AdminCount'
- Value = 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 +520,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 +653,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 +677,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..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
- - `EnsureAttribute` 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).
@@ -159,12 +161,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 f9e461c1..3f56767e 100644
--- a/docs/reference/steps.md
+++ b/docs/reference/steps.md
@@ -10,7 +10,7 @@
| [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. |
| [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-attributes.md
similarity index 57%
rename from docs/reference/steps/step-ensure-attribute.md
rename to docs/reference/steps/step-ensure-attributes.md
index 5c6aa2d7..21107318 100644
--- a/docs/reference/steps/step-ensure-attribute.md
+++ b/docs/reference/steps/step-ensure-attributes.md
@@ -1,27 +1,31 @@
-# IdLE.Step.EnsureAttribute
+# IdLE.Step.EnsureAttributes
> Generated file. Do not edit by hand.
> Source: tools/Generate-IdleStepReference.ps1
## Summary
-- **Step Type**: `IdLE.Step.EnsureAttribute`
+- **Step Type**: `IdLE.Step.EnsureAttributes`
- **Module**: `IdLE.Steps.Common`
-- **Implementation**: `Invoke-IdleStepEnsureAttribute`
+- **Implementation**: `Invoke-IdleStepEnsureAttributes`
- **Idempotent**: `Yes`
## Synopsis
-Ensures that an identity attribute matches the desired value.
+Ensures that multiple identity attributes match their desired values.
## 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'.
+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>].
-The step is idempotent by design: it converges state to the desired value.
+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:
@@ -40,20 +44,18 @@ 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 |
-| `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'
+ Name = 'IdLE.Step.EnsureAttributes Example'
+ Type = 'IdLE.Step.EnsureAttributes'
With = @{
+ Attributes = @{ GivenName = 'First'; Surname = 'Last' }
IdentityKey = 'user.name'
- Name = 'AttributeName'
- Value = 'AttributeValue'
}
}
```
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/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..3229a582 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -13,7 +13,7 @@ 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-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
@@ -126,7 +126,7 @@ 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 |
| joiner-with-onfailure.psd1 | Mock | ✅ Yes | Identity (Mock) | None |
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-ensureattributes.psd1
similarity index 63%
rename from examples/workflows/mock/joiner-minimal-ensureattribute.psd1
rename to examples/workflows/mock/joiner-minimal-ensureattributes.psd1
index 38e56638..61f556a3 100644
--- a/examples/workflows/mock/joiner-minimal-ensureattribute.psd1
+++ b/examples/workflows/mock/joiner-minimal-ensureattributes.psd1
@@ -1,5 +1,5 @@
@{
- Name = 'Joiner - Minimal (EnsureAttribute)'
+ Name = 'Joiner - Minimal (EnsureAttributes)'
LifecycleEvent = 'Joiner'
Steps = @(
@@ -12,13 +12,16 @@
}
@{
- Name = 'Ensure Department'
- Type = 'IdLE.Step.EnsureAttribute'
+ Name = 'Ensure user attributes'
+ Type = 'IdLE.Step.EnsureAttributes'
With = @{
Provider = 'Identity'
IdentityKey = 'user1'
- Name = 'Department'
- Value = 'IT'
+ Attributes = @{
+ Department = 'IT'
+ Title = 'Engineer'
+ Office = 'Building A'
+ }
}
}
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 2a4cc497..9e7952fc 100644
--- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1
+++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1
@@ -114,10 +114,10 @@ function Get-IdleStepRegistry {
}
}
- if (-not $registry.ContainsKey('IdLE.Step.EnsureAttribute')) {
- $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureAttribute' -ModuleName 'IdLE.Steps.Common'
+ 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.EnsureAttribute'] = $handler
+ $registry['IdLE.Step.EnsureAttributes'] = $handler
}
}
diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1
index 88389488..53ed2607 100644
--- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1
+++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psd1
@@ -13,7 +13,7 @@
FunctionsToExport = @(
'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..5437357c 100644
--- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1
+++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1
@@ -47,7 +47,7 @@ if (Test-Path -Path $PublicPath) {
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..56d90c7b 100644
--- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1
+++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1
@@ -53,8 +53,8 @@ function Get-IdleStepMetadataCatalog {
RequiredCapabilities = @('IdLE.Identity.Move')
}
- # IdLE.Step.EnsureAttribute - requires identity attribute ensure capability
- $catalog['IdLE.Step.EnsureAttribute'] = @{
+ # 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 e7842254..00000000
--- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1
+++ /dev/null
@@ -1,85 +0,0 @@
-function Invoke-IdleStepEnsureAttribute {
- <#
- .SYNOPSIS
- 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'.
-
- 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."
- }
- }
-
- $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."
- }
-
- $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
- }
-}
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..d89bf137
--- /dev/null
+++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1
@@ -0,0 +1,165 @@
+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
+ $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 {
+ # 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 # Overall result - individual changes unknown without provider details
+ Error = $null
+ }
+ }
+ }
+ }
+ 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 {
+
+ 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/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
diff --git a/tests/Packaging/ModuleSurface.Tests.ps1 b/tests/Packaging/ModuleSurface.Tests.ps1
index 3ec10fab..cf60039c 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$'
@@ -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 69d488be..ba81cf35 100644
--- a/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1
+++ b/tests/Steps/Invoke-IdleStepAuthSession.Tests.ps1
@@ -56,18 +56,17 @@ 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'
- 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
@@ -117,16 +116,15 @@ 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'
- 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
@@ -176,17 +174,16 @@ 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'
- 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
@@ -239,17 +236,16 @@ 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'
- 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
@@ -280,18 +276,17 @@ 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'
- 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*'
}
@@ -341,17 +336,16 @@ 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'
- 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
@@ -385,17 +379,16 @@ 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'
- 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*'
}
}
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
+ }
+ }
+}
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',