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',