diff --git a/docs/reference/providers/provider-exchangeonline.md b/docs/reference/providers/provider-exchangeonline.md index 6b12ee06..6a91ae67 100644 --- a/docs/reference/providers/provider-exchangeonline.md +++ b/docs/reference/providers/provider-exchangeonline.md @@ -187,11 +187,11 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers Steps = @( @{ Name = 'Ensure mailbox type' - Type = 'IdLE.Step.MailboxType.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' - Type = 'Shared' + MailboxType = 'Shared' # AuthSessionName is optional; defaults to the provider alias if omitted # AuthSessionOptions = @{ ... } } @@ -200,6 +200,101 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers } ``` +### OOF with template variables and dynamic manager attributes + +This example shows how to use template variables (`{{...}}`) in Out of Office messages +with dynamic user attributes (e.g., manager information). Templates are resolved during +plan building against the request object. + +**Important:** Manager lookup is performed **host-side**, not inside the step. This +maintains the security boundary: steps do not perform directory lookups. + +**Host enrichment (example using AD):** + +```powershell +# 1. Retrieve user and manager details from AD +$user = Get-ADUser -Identity 'max.power' -Properties Manager +$mgr = $null + +if ($user.Manager) { + $mgr = Get-ADUser -Identity $user.Manager -Properties DisplayName, Mail +} + +# Provide fallback contact if no manager is found +if (-not $mgr) { + $mgr = [PSCustomObject]@{ + DisplayName = 'IT Support' + Mail = 'support@contoso.com' + } +} + +# 2. Build request with manager data in DesiredState +$req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor $env:USERNAME ` + -Input @{ UserPrincipalName = 'max.power@contoso.com' } ` + -DesiredState @{ + Manager = @{ + DisplayName = $mgr.DisplayName + Mail = $mgr.Mail + } + } + +# 3. Plan and execute +$plan = New-IdlePlan -WorkflowPath './leaver-workflow.psd1' -Request $req -Providers $providers +$result = Invoke-IdlePlan -Plan $plan -Providers $providers +``` + +**Workflow step using templates:** + +```powershell +@{ + Name = 'Set Exchange OOF' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + Config = @{ + Mode = 'Enabled' + InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.DisplayName}} ({{Request.DesiredState.Manager.Mail}}).' + ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.Mail}}.' + ExternalAudience = 'All' + } + } +} +``` + +**Alternative (using Entra ID / Microsoft Graph):** + +```powershell +# Host enrichment using Microsoft Graph +Connect-MgGraph -Scopes 'User.Read.All' + +$user = Get-MgUser -UserId 'max.power@contoso.com' -Property 'Manager' +$mgr = if ($user.Manager.Id) { + Get-MgUser -UserId $user.Manager.Id -Property 'DisplayName', 'Mail' +} else { $null } + +# Provide fallback contact if no manager is found +if (-not $mgr) { + $mgr = [PSCustomObject]@{ + DisplayName = 'IT Support' + Mail = 'support@contoso.com' + } +} + +$req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor $env:USERNAME ` + -Input @{ UserPrincipalName = 'max.power@contoso.com' } ` + -DesiredState @{ + Manager = @{ + DisplayName = $mgr.DisplayName + Mail = $mgr.Mail + } + } +``` + --- ## Limitations and known issues diff --git a/docs/reference/steps.md b/docs/reference/steps.md index 9b6ddee5..a89fea83 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -5,12 +5,15 @@ | Step Type | Module | Synopsis | | --- | --- | --- | -| [CreateIdentity](steps/step-create-identity.md) | ``IdLE.Steps.Common`` | Creates a new identity in the target system. | -| [DeleteIdentity](steps/step-delete-identity.md) | ``IdLE.Steps.Common`` | Deletes an identity from the target system. | -| [DisableIdentity](steps/step-disable-identity.md) | ``IdLE.Steps.Common`` | Disables an identity in the target system. | -| [EmitEvent](steps/step-emit-event.md) | ``IdLE.Steps.Common`` | Emits a custom event (demo step). | -| [EnableIdentity](steps/step-enable-identity.md) | ``IdLE.Steps.Common`` | Enables an identity in the target system. | -| [EnsureAttribute](steps/step-ensure-attribute.md) | ``IdLE.Steps.Common`` | Ensures that an identity attribute matches the desired value. | -| [EnsureEntitlement](steps/step-ensure-entitlement.md) | ``IdLE.Steps.Common`` | Ensures that an entitlement assignment is present or absent for an identity. | -| [MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | -| [TriggerDirectorySync](steps/step-trigger-directory-sync.md) | ``IdLE.Steps.DirectorySync`` | Triggers a directory sync cycle and optionally waits for completion. | +| [IdLE.Step.CreateIdentity](steps/step-create-identity.md) | ``IdLE.Steps.Common`` | Creates a new identity in the target system. | +| [IdLE.Step.DeleteIdentity](steps/step-delete-identity.md) | ``IdLE.Steps.Common`` | Deletes an identity from the target system. | +| [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.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). | +| [IdLE.Step.Mailbox.GetInfo](steps/step-mailbox-get-info.md) | ``IdLE.Steps.Mailbox`` | Retrieves mailbox details and returns a structured report. | +| [IdLE.Step.MoveIdentity](steps/step-move-identity.md) | ``IdLE.Steps.Common`` | Moves an identity to a different container/OU in the target system. | +| [IdLE.Step.TriggerDirectorySync](steps/step-trigger-directory-sync.md) | ``IdLE.Steps.DirectorySync`` | Triggers a directory sync cycle and optionally waits for completion. | diff --git a/docs/reference/steps/step-create-identity.md b/docs/reference/steps/step-create-identity.md index 0bb9f0a5..3cb97cc0 100644 --- a/docs/reference/steps/step-create-identity.md +++ b/docs/reference/steps/step-create-identity.md @@ -1,15 +1,14 @@ -# CreateIdentity +# IdLE.Step.CreateIdentity > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `CreateIdentity` +- **Step Type**: `IdLE.Step.CreateIdentity` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepCreateIdentity` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Identity.Create` ## Synopsis @@ -48,7 +47,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'CreateIdentity Example' + Name = 'IdLE.Step.CreateIdentity Example' Type = 'IdLE.Step.CreateIdentity' With = @{ Attributes = @{ GivenName = 'First'; Surname = 'Last' } @@ -59,5 +58,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-delete-identity.md b/docs/reference/steps/step-delete-identity.md index c4b01633..1fbdb21e 100644 --- a/docs/reference/steps/step-delete-identity.md +++ b/docs/reference/steps/step-delete-identity.md @@ -1,15 +1,14 @@ -# DeleteIdentity +# IdLE.Step.DeleteIdentity > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `DeleteIdentity` +- **Step Type**: `IdLE.Step.DeleteIdentity` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepDeleteIdentity` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Identity.Delete` ## Synopsis @@ -51,7 +50,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'DeleteIdentity Example' + Name = 'IdLE.Step.DeleteIdentity Example' Type = 'IdLE.Step.DeleteIdentity' With = @{ IdentityKey = 'user.name' @@ -61,5 +60,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-disable-identity.md b/docs/reference/steps/step-disable-identity.md index 35f00476..467c9231 100644 --- a/docs/reference/steps/step-disable-identity.md +++ b/docs/reference/steps/step-disable-identity.md @@ -1,15 +1,14 @@ -# DisableIdentity +# IdLE.Step.DisableIdentity > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `DisableIdentity` +- **Step Type**: `IdLE.Step.DisableIdentity` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepDisableIdentity` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Identity.Disable` ## Synopsis @@ -47,7 +46,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'DisableIdentity Example' + Name = 'IdLE.Step.DisableIdentity Example' Type = 'IdLE.Step.DisableIdentity' With = @{ IdentityKey = 'user.name' @@ -57,5 +56,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-emit-event.md b/docs/reference/steps/step-emit-event.md index 61bbaeb3..6bb78348 100644 --- a/docs/reference/steps/step-emit-event.md +++ b/docs/reference/steps/step-emit-event.md @@ -1,11 +1,11 @@ -# EmitEvent +# IdLE.Step.EmitEvent > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `EmitEvent` +- **Step Type**: `IdLE.Step.EmitEvent` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepEmitEvent` - **Idempotent**: `Unknown` @@ -29,7 +29,7 @@ Please refer to the step description and examples for usage details. ```powershell @{ - Name = 'EmitEvent Example' + Name = 'IdLE.Step.EmitEvent Example' Type = 'IdLE.Step.EmitEvent' With = @{ # See step description for available options diff --git a/docs/reference/steps/step-enable-identity.md b/docs/reference/steps/step-enable-identity.md index 90051687..7ad050eb 100644 --- a/docs/reference/steps/step-enable-identity.md +++ b/docs/reference/steps/step-enable-identity.md @@ -1,15 +1,14 @@ -# EnableIdentity +# IdLE.Step.EnableIdentity > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `EnableIdentity` +- **Step Type**: `IdLE.Step.EnableIdentity` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepEnableIdentity` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Identity.Enable` ## Synopsis @@ -47,7 +46,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'EnableIdentity Example' + Name = 'IdLE.Step.EnableIdentity Example' Type = 'IdLE.Step.EnableIdentity' With = @{ IdentityKey = 'user.name' @@ -57,5 +56,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-ensure-attribute.md b/docs/reference/steps/step-ensure-attribute.md index 84edda1d..5c6aa2d7 100644 --- a/docs/reference/steps/step-ensure-attribute.md +++ b/docs/reference/steps/step-ensure-attribute.md @@ -1,15 +1,14 @@ -# EnsureAttribute +# IdLE.Step.EnsureAttribute > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `EnsureAttribute` +- **Step Type**: `IdLE.Step.EnsureAttribute` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepEnsureAttribute` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Identity.Attribute.Ensure` ## Synopsis @@ -49,7 +48,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'EnsureAttribute Example' + Name = 'IdLE.Step.EnsureAttribute Example' Type = 'IdLE.Step.EnsureAttribute' With = @{ IdentityKey = 'user.name' @@ -61,5 +60,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-ensure-entitlement.md b/docs/reference/steps/step-ensure-entitlement.md index 80d36180..c714c536 100644 --- a/docs/reference/steps/step-ensure-entitlement.md +++ b/docs/reference/steps/step-ensure-entitlement.md @@ -1,15 +1,14 @@ -# EnsureEntitlement +# IdLE.Step.EnsureEntitlement > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `EnsureEntitlement` +- **Step Type**: `IdLE.Step.EnsureEntitlement` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepEnsureEntitlement` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Entitlement.List`, `IdLE.Entitlement.Grant`, `IdLE.Entitlement.Revoke` ## Synopsis @@ -55,7 +54,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'EnsureEntitlement Example' + Name = 'IdLE.Step.EnsureEntitlement Example' Type = 'IdLE.Step.EnsureEntitlement' With = @{ Entitlement = @{ Kind = 'Group'; Id = 'GroupId'; DisplayName = 'Example Group' } @@ -67,5 +66,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-mailbox-ensure-out-of-office.md b/docs/reference/steps/step-mailbox-ensure-out-of-office.md new file mode 100644 index 00000000..0d381576 --- /dev/null +++ b/docs/reference/steps/step-mailbox-ensure-out-of-office.md @@ -0,0 +1,75 @@ +# IdLE.Step.Mailbox.EnsureOutOfOffice + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.Mailbox.EnsureOutOfOffice` +- **Module**: `IdLE.Steps.Mailbox` +- **Implementation**: `Invoke-IdleStepMailboxOutOfOfficeEnsure` +- **Idempotent**: `Yes` + +## Synopsis + +Ensures that a mailbox Out of Office (OOF) configuration matches the desired state. + +## Description + +The host must supply a provider instance via +Context.Providers[<ProviderAlias>]. The provider must implement an EnsureOutOfOffice +method with the signature (IdentityKey, Config, AuthSession) and return an object +that contains a boolean property 'Changed'. + +The step is idempotent by design: it converges OOF configuration to the desired state. + +Out of Office Config shape (data-only hashtable): + +- Mode: 'Disabled' | 'Enabled' | 'Scheduled' (required) + +- Start: DateTime (required when Mode = 'Scheduled') + +- End: DateTime (required when Mode = 'Scheduled') + +- InternalMessage: string (optional) + +- ExternalMessage: string (optional) + +- ExternalAudience: 'None' | 'Known' | 'All' (optional, default provider-specific) + +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 With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Admin' \}). + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `Config` | Yes | See step description for details | +| `IdentityKey` | Yes | Unique identifier for the identity | + +## Example + +```powershell +@{ + Name = 'IdLE.Step.Mailbox.EnsureOutOfOffice Example' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Config = '' + IdentityKey = 'user.name' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-mailbox-ensure-type.md b/docs/reference/steps/step-mailbox-ensure-type.md new file mode 100644 index 00000000..4f89f8e9 --- /dev/null +++ b/docs/reference/steps/step-mailbox-ensure-type.md @@ -0,0 +1,71 @@ +# IdLE.Step.Mailbox.EnsureType + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.Mailbox.EnsureType` +- **Module**: `IdLE.Steps.Mailbox` +- **Implementation**: `Invoke-IdleStepMailboxTypeEnsure` +- **Idempotent**: `Yes` + +## Synopsis + +Ensures that a mailbox is of the desired type (User, Shared, Room, Equipment). + +## Description + +The host must supply a provider instance via +Context.Providers[<ProviderAlias>]. The provider must implement an EnsureMailboxType +method with the signature (IdentityKey, MailboxType, AuthSession) and return an object +that contains a boolean property 'Changed'. + +The step is idempotent by design: it converges state to the desired type. + +Supported mailbox types: + +- User (regular user mailbox) + +- Shared (shared mailbox for team use) + +- Room (room resource mailbox) + +- Equipment (equipment resource mailbox) + +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 With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Admin' \}). + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity | +| `MailboxType` | Yes | See step description for details | + +## Example + +```powershell +@{ + Name = 'IdLE.Step.Mailbox.EnsureType Example' + Type = 'IdLE.Step.Mailbox.EnsureType' + With = @{ + IdentityKey = 'user.name' + MailboxType = '' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-mailbox-get-info.md b/docs/reference/steps/step-mailbox-get-info.md new file mode 100644 index 00000000..4e772fe7 --- /dev/null +++ b/docs/reference/steps/step-mailbox-get-info.md @@ -0,0 +1,58 @@ +# IdLE.Step.Mailbox.GetInfo + +> Generated file. Do not edit by hand. +> Source: tools/Generate-IdleStepReference.ps1 + +## Summary + +- **Step Type**: `IdLE.Step.Mailbox.GetInfo` +- **Module**: `IdLE.Steps.Mailbox` +- **Implementation**: `Invoke-IdleStepMailboxGetInfo` +- **Idempotent**: `Unknown` + +## Synopsis + +Retrieves mailbox details and returns a structured report. + +## Description + +The host must supply a provider instance via +Context.Providers[<ProviderAlias>]. The provider must implement a GetMailbox +method with the signature (IdentityKey, AuthSession) and return a mailbox object. + +The step is read-only and returns Changed = $false. + +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 With.AuthSessionName is absent, defaults to With.Provider value (e.g., 'ExchangeOnline'). + +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @\{ Role = 'Admin' \}). + +## Inputs (With.*) + +The following keys are required in the step's ``With`` configuration: + +| Key | Required | Description | +| --- | --- | --- | +| `IdentityKey` | Yes | Unique identifier for the identity | + +## Example + +```powershell +@{ + Name = 'IdLE.Step.Mailbox.GetInfo Example' + Type = 'IdLE.Step.Mailbox.GetInfo' + With = @{ + IdentityKey = 'user.name' + } +} +``` + +## See Also + +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities +- [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-move-identity.md b/docs/reference/steps/step-move-identity.md index a69d8266..0d7bde8a 100644 --- a/docs/reference/steps/step-move-identity.md +++ b/docs/reference/steps/step-move-identity.md @@ -1,15 +1,14 @@ -# MoveIdentity +# IdLE.Step.MoveIdentity > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `MoveIdentity` +- **Step Type**: `IdLE.Step.MoveIdentity` - **Module**: `IdLE.Steps.Common` - **Implementation**: `Invoke-IdleStepMoveIdentity` - **Idempotent**: `Yes` -- **Required Capabilities**: `IdLE.Identity.Move` ## Synopsis @@ -48,7 +47,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'MoveIdentity Example' + Name = 'IdLE.Step.MoveIdentity Example' Type = 'IdLE.Step.MoveIdentity' With = @{ IdentityKey = 'user.name' @@ -59,5 +58,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/docs/reference/steps/step-trigger-directory-sync.md b/docs/reference/steps/step-trigger-directory-sync.md index c80813e2..f3f01743 100644 --- a/docs/reference/steps/step-trigger-directory-sync.md +++ b/docs/reference/steps/step-trigger-directory-sync.md @@ -1,15 +1,14 @@ -# TriggerDirectorySync +# IdLE.Step.TriggerDirectorySync > Generated file. Do not edit by hand. > Source: tools/Generate-IdleStepReference.ps1 ## Summary -- **Step Type**: `TriggerDirectorySync` +- **Step Type**: `IdLE.Step.TriggerDirectorySync` - **Module**: `IdLE.Steps.DirectorySync` - **Implementation**: `Invoke-IdleStepTriggerDirectorySync` - **Idempotent**: `Unknown` -- **Required Capabilities**: `IdLE.DirectorySync.Trigger`, `IdLE.DirectorySync.Status` ## Synopsis @@ -48,7 +47,7 @@ The following keys are required in the step's ``With`` configuration: ```powershell @{ - Name = 'TriggerDirectorySync Example' + Name = 'IdLE.Step.TriggerDirectorySync Example' Type = 'IdLE.Step.TriggerDirectorySync' With = @{ AuthSessionName = 'AdminSession' @@ -59,5 +58,5 @@ The following keys are required in the step's ``With`` configuration: ## See Also -- [Capabilities Reference](../capabilities.md) - Details on required capabilities +- [Capabilities Reference](../capabilities.md) - Overview of IdLE capabilities - [Providers](../providers.md) - Available provider implementations diff --git a/examples/Invoke-LeaverWithManagerOOF.ps1 b/examples/Invoke-LeaverWithManagerOOF.ps1 new file mode 100644 index 00000000..e74f246e --- /dev/null +++ b/examples/Invoke-LeaverWithManagerOOF.ps1 @@ -0,0 +1,206 @@ +<# +.SYNOPSIS +Host example showing request enrichment with manager data for OOF templates. + +.DESCRIPTION +This script demonstrates how to enrich a lifecycle request with manager information +from Active Directory or Entra ID before executing a leaver workflow that uses +template variables in Out of Office messages. + +Key concepts: +- Manager lookup is performed HOST-SIDE, not inside workflow steps +- Request enrichment happens before calling New-IdlePlan +- Templates like {{Request.DesiredState.Manager.DisplayName}} are resolved during planning + +.NOTES +This is an example only. Adapt authentication, provider setup, and directory queries +to your environment. +#> + +[CmdletBinding()] +param( + # User principal name of the leaver + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $UserPrincipalName, + + # Directory source for manager lookup + [Parameter()] + [ValidateSet('AD', 'EntraID')] + [string] $DirectorySource = 'AD', + + # Path to the leaver workflow + [Parameter()] + [string] $WorkflowPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Resolve paths relative to script location for portability +if ([string]::IsNullOrWhiteSpace($WorkflowPath)) { + $WorkflowPath = Join-Path $PSScriptRoot 'workflows' 'templates' 'exo-leaver-mailbox-offboarding.psd1' + $WorkflowPath = (Resolve-Path -LiteralPath $WorkflowPath -ErrorAction Stop).Path +} + +# Import IdLE module +$idleModulePath = Join-Path $PSScriptRoot '..' 'src' 'IdLE' 'IdLE.psd1' +$idleModulePath = (Resolve-Path -LiteralPath $idleModulePath -ErrorAction Stop).Path +Import-Module $idleModulePath -Force + +Write-Host "==> Enriching request with manager data from $DirectorySource..." -ForegroundColor Cyan + +# 1. Retrieve manager information (host-side lookup) +$managerInfo = $null + +switch ($DirectorySource) { + 'AD' { + # Active Directory example + Write-Host "Querying Active Directory for user: $UserPrincipalName" + + # Extract sAMAccountName from UPN if needed + $samAccountName = $UserPrincipalName.Split('@')[0] + + $user = Get-ADUser -Identity $samAccountName -Properties Manager -ErrorAction Stop + + if ($user.Manager) { + Write-Host " Found manager DN: $($user.Manager)" + $mgr = Get-ADUser -Identity $user.Manager -Properties DisplayName, Mail -ErrorAction Stop + + $managerInfo = @{ + DisplayName = $mgr.DisplayName + Mail = $mgr.Mail + } + + Write-Host " Manager: $($managerInfo.DisplayName) <$($managerInfo.Mail)>" -ForegroundColor Green + } + else { + Write-Warning " No manager found for user $UserPrincipalName in AD." + } + } + + 'EntraID' { + # Microsoft Graph / Entra ID example + Write-Host "Querying Entra ID for user: $UserPrincipalName" + + # Ensure Microsoft.Graph module is available + if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Users)) { + throw "Microsoft.Graph.Users module is required. Install with: Install-Module Microsoft.Graph.Users" + } + + Import-Module Microsoft.Graph.Users + + # Connect to Graph (assumes already authenticated or will prompt) + $null = Connect-MgGraph -Scopes 'User.Read.All' -NoWelcome -ErrorAction Stop + + $user = Get-MgUser -UserId $UserPrincipalName -Property 'Manager' -ErrorAction Stop + + if ($user.Manager.Id) { + Write-Host " Found manager ID: $($user.Manager.Id)" + $mgr = Get-MgUser -UserId $user.Manager.Id -Property 'DisplayName', 'Mail' -ErrorAction Stop + + $managerInfo = @{ + DisplayName = $mgr.DisplayName + Mail = $mgr.Mail + } + + Write-Host " Manager: $($managerInfo.DisplayName) <$($managerInfo.Mail)>" -ForegroundColor Green + } + else { + Write-Warning " No manager found for user $UserPrincipalName in Entra ID." + } + } +} + +# 2. Build lifecycle request with enriched DesiredState +Write-Host "==> Building lifecycle request..." -ForegroundColor Cyan + +$desiredState = @{} + +if ($managerInfo) { + $desiredState['Manager'] = $managerInfo +} +else { + # Fallback: use generic support contact + Write-Warning "No manager found; using generic support contact in OOF message." + $desiredState['Manager'] = @{ + DisplayName = 'IT Support' + Mail = 'support@contoso.com' + } +} + +$request = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor $env:USERNAME ` + -Input @{ + UserPrincipalName = $UserPrincipalName + } ` + -DesiredState $desiredState + +Write-Host " Request CorrelationId: $($request.CorrelationId)" -ForegroundColor Gray + +# 3. Set up providers +Write-Host "==> Setting up providers..." -ForegroundColor Cyan + +# For this example, we'll use mock providers (replace with real providers in production) +Import-Module ./src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1 -Force +Import-Module ./src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1 -Force + +$exoProvider = New-IdleMockProvider -Name 'ExchangeOnline' -Capabilities @( + 'IdLE.Mailbox.Info.Read' + 'IdLE.Mailbox.Type.Ensure' + 'IdLE.Mailbox.OutOfOffice.Ensure' +) + +$authBroker = New-IdleAuthSessionBroker ` + -AuthSessionType 'OAuth' ` + -DefaultAuthSession ([pscustomobject]@{ Token = 'mock-token' }) + +$providers = @{ + ExchangeOnline = $exoProvider + AuthSessionBroker = $authBroker +} + +# 4. Build plan (templates are resolved here) +Write-Host "==> Building execution plan..." -ForegroundColor Cyan + +$plan = New-IdlePlan ` + -WorkflowPath $WorkflowPath ` + -Request $request ` + -Providers $providers + +Write-Host " Plan Name: $($plan.WorkflowName)" +Write-Host " Steps: $($plan.Steps.Count)" + +# Show resolved template values +$oofStep = $plan.Steps | Where-Object { $_.Type -like '*OutOfOffice*' } | Select-Object -First 1 +if ($oofStep) { + Write-Host "" + Write-Host " OOF Internal Message (template resolved):" -ForegroundColor Yellow + Write-Host " $($oofStep.With.Config.InternalMessage)" -ForegroundColor Gray + Write-Host " OOF External Message (template resolved):" -ForegroundColor Yellow + Write-Host " $($oofStep.With.Config.ExternalMessage)" -ForegroundColor Gray +} + +# 5. Execute plan +Write-Host "" +Write-Host "==> Executing plan..." -ForegroundColor Cyan + +$result = Invoke-IdlePlan ` + -Plan $plan ` + -Providers $providers + +Write-Host " Status: $($result.Status)" -ForegroundColor $(if ($result.Status -eq 'Completed') { 'Green' } else { 'Red' }) +Write-Host " Steps executed: $($result.Steps.Count)" + +foreach ($step in $result.Steps) { + $statusColor = switch ($step.Status) { + 'Completed' { 'Green' } + 'Skipped' { 'Yellow' } + default { 'Red' } + } + Write-Host " - $($step.Name): $($step.Status)" -ForegroundColor $statusColor +} + +Write-Host "" +Write-Host "==> Done." -ForegroundColor Cyan diff --git a/examples/workflows/templates/complete-leaver-entraid-exo.psd1 b/examples/workflows/templates/complete-leaver-entraid-exo.psd1 index 0e0fa2d4..c7c42f72 100644 --- a/examples/workflows/templates/complete-leaver-entraid-exo.psd1 +++ b/examples/workflows/templates/complete-leaver-entraid-exo.psd1 @@ -13,7 +13,7 @@ } @{ Name = 'ConvertToSharedMailbox' - Type = 'IdLE.Step.Mailbox.Type.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } @@ -22,7 +22,7 @@ } @{ Name = 'EnableOutOfOffice' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } diff --git a/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 b/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 index 713de5fc..f0f90b77 100644 --- a/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 +++ b/examples/workflows/templates/exo-leaver-mailbox-offboarding.psd1 @@ -1,7 +1,7 @@ @{ Name = 'ExchangeOnline Leaver - Mailbox Offboarding' LifecycleEvent = 'Leaver' - Description = 'Converts mailbox to shared, enables Out of Office, and optionally delegates access for offboarding users.' + Description = 'Converts mailbox to shared, enables Out of Office with dynamic manager contact info, and optionally delegates access for offboarding users.' Steps = @( @{ Name = 'GetMailboxInfo' @@ -13,7 +13,7 @@ } @{ Name = 'ConvertToSharedMailbox' - Type = 'IdLE.Step.Mailbox.Type.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } @@ -21,15 +21,15 @@ } } @{ - Name = 'EnableOutOfOffice' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Name = 'EnableOutOfOfficeWithManagerContact' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } Config = @{ Mode = 'Enabled' - InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' - ExternalMessage = 'This person is no longer with the organization. Please contact the main office for assistance.' + InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.DisplayName}} ({{Request.DesiredState.Manager.Mail}}).' + ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.Mail}}.' ExternalAudience = 'All' } } diff --git a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 index 0b528ee4..d5737679 100644 --- a/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 +++ b/src/IdLE.Core/Private/Assert-IdleNoScriptBlock.ps1 @@ -42,6 +42,12 @@ function Assert-IdleNoScriptBlock { # PSCustomObject (walk note properties) if ($InputObject -is [pscustomobject]) { + # Exempt trusted IdLE types that legitimately contain ScriptBlocks + # AuthSessionBroker contains ValidateAuthSession scriptblock which is an internal implementation detail + if ($InputObject.PSTypeNames -contains 'IdLE.AuthSessionBroker') { + return + } + foreach ($p in $InputObject.PSObject.Properties) { if ($p.MemberType -eq 'NoteProperty') { # PSPropertyInfo does not expose "InputObject" here; the value is in .Value. diff --git a/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 b/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 index 8dabd206..21dc7684 100644 --- a/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 +++ b/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 @@ -108,6 +108,11 @@ function Copy-IdleRedactedObject { return $RedactionMarker } + # Redact ScriptBlocks to avoid complex nested structures and potential cycles + if ($InnerValue -is [scriptblock]) { + return $RedactionMarker + } + # Primitive / immutable-ish types can be returned as-is. if ($InnerValue -is [string] -or $InnerValue -is [int] -or diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 365c8682..83bcc64d 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -170,5 +170,27 @@ function Get-IdleStepRegistry { } } + # Mailbox steps (IdLE.Steps.Mailbox module) + if (-not $registry.ContainsKey('IdLE.Step.Mailbox.EnsureOutOfOffice')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepMailboxOutOfOfficeEnsure' -ModuleName 'IdLE.Steps.Mailbox' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.Mailbox.EnsureOutOfOffice'] = $handler + } + } + + if (-not $registry.ContainsKey('IdLE.Step.Mailbox.GetInfo')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepMailboxGetInfo' -ModuleName 'IdLE.Steps.Mailbox' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.Mailbox.GetInfo'] = $handler + } + } + + if (-not $registry.ContainsKey('IdLE.Step.Mailbox.EnsureType')) { + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepMailboxTypeEnsure' -ModuleName 'IdLE.Steps.Mailbox' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.Mailbox.EnsureType'] = $handler + } + } + return $registry } diff --git a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 index efc17e47..c779e5ca 100644 --- a/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Get-IdleStepMetadataCatalog.ps1 @@ -31,13 +31,13 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Mailbox.Info.Read') } - # IdLE.Step.Mailbox.Type.Ensure - idempotent mailbox type conversion - $catalog['IdLE.Step.Mailbox.Type.Ensure'] = @{ + # IdLE.Step.Mailbox.EnsureType - idempotent mailbox type conversion + $catalog['IdLE.Step.Mailbox.EnsureType'] = @{ RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.Type.Ensure') } - # IdLE.Step.Mailbox.OutOfOffice.Ensure - idempotent Out of Office configuration - $catalog['IdLE.Step.Mailbox.OutOfOffice.Ensure'] = @{ + # IdLE.Step.Mailbox.EnsureOutOfOffice - idempotent Out of Office configuration + $catalog['IdLE.Step.Mailbox.EnsureOutOfOffice'] = @{ RequiredCapabilities = @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 5f371877..d3d4dcb2 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -39,7 +39,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # In workflow definition (enable OOF): @{ Name = 'Enable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -56,7 +56,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # In workflow definition (with ValueFrom for dynamic values): @{ Name = 'Enable Out of Office for Leaver' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } @@ -73,7 +73,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # In workflow definition (scheduled OOF): @{ Name = 'Schedule Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -91,7 +91,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # In workflow definition (disable OOF): @{ Name = 'Disable Out of Office' - Type = 'IdLE.Step.Mailbox.OutOfOffice.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' @@ -100,6 +100,42 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { } } } + + .EXAMPLE + # Template usage with dynamic manager attributes (Leaver scenario): + # Note: Templates are resolved during planning against the request object. + # Host must enrich request.DesiredState with manager data before calling New-IdlePlan. + + # Host-side enrichment (example): + # $user = Get-ADUser -Identity 'max.power' -Properties Manager + # $mgr = if ($user.Manager) { + # Get-ADUser -Identity $user.Manager -Properties DisplayName, Mail + # } else { + # # Fallback manager/contact to avoid null template values + # [pscustomobject]@{ + # DisplayName = 'Service Desk' + # Mail = 'servicedesk@contoso.com' + # } + # } + # $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -DesiredState @{ + # Manager = @{ DisplayName = $mgr.DisplayName; Mail = $mgr.Mail } + # } + + # Workflow step with template variables: + @{ + Name = 'Set OOF with Manager Contact' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'max.power@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.DisplayName}} ({{Request.DesiredState.Manager.Mail}}).' + ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.Mail}}.' + ExternalAudience = 'All' + } + } + } #> [CmdletBinding()] param( diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 index 6878dda0..6c848abd 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxTypeEnsure.ps1 @@ -37,7 +37,7 @@ function Invoke-IdleStepMailboxTypeEnsure { # In workflow definition (convert to shared mailbox): @{ Name = 'Convert to shared mailbox' - Type = 'IdLE.Step.Mailbox.Type.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' IdentityKey = 'user@contoso.com' diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 5a583fd8..1a700f7e 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -8,7 +8,7 @@ Provider-agnostic mailbox step pack for IdLE. # Step example: Convert to shared mailbox @{ Name = 'ConvertToSharedMailbox' - Type = 'IdLE.Step.Mailbox.Type.Ensure' + Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } @@ -20,8 +20,8 @@ Provider-agnostic mailbox step pack for IdLE. ## Step Types - **IdLE.Step.Mailbox.GetInfo** - Read mailbox details -- **IdLE.Step.Mailbox.Type.Ensure** - Convert mailbox type (User/Shared/Room/Equipment) -- **IdLE.Step.Mailbox.OutOfOffice.Ensure** - Configure Out of Office settings +- **IdLE.Step.Mailbox.EnsureType** - Convert mailbox type (User/Shared/Room/Equipment) +- **IdLE.Step.Mailbox.EnsureOutOfOffice** - Configure Out of Office settings ## Documentation diff --git a/tests/Core/CapabilityDeprecation.Tests.ps1 b/tests/Core/CapabilityDeprecation.Tests.ps1 index d3dadd88..f88d9e5e 100644 --- a/tests/Core/CapabilityDeprecation.Tests.ps1 +++ b/tests/Core/CapabilityDeprecation.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + Import-IdleTestMailboxModule # Import mailbox steps module for capability metadata $mailboxStepsPath = Join-Path $PSScriptRoot '..' '..' 'src' 'IdLE.Steps.Mailbox' 'IdLE.Steps.Mailbox.psd1' @@ -32,7 +33,12 @@ Describe 'Capability Deprecation and Migration' { # Verify the workflow file exists $wfPath | Should -Exist - $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + Manager = @{ + DisplayName = 'IT Support' + Mail = 'support@contoso.com' + } + } $providers = @{ MockProvider = $mockProvider } # Planning should succeed and emit a deprecation warning @@ -67,7 +73,12 @@ Describe 'Capability Deprecation and Migration' { # Use a real workflow file $wfPath = Join-Path $PSScriptRoot '..' '..' 'examples' 'workflows' 'templates' 'exo-leaver-mailbox-offboarding.psd1' - $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' + $req = New-IdleLifecycleRequest -LifecycleEvent 'Leaver' -DesiredState @{ + Manager = @{ + DisplayName = 'IT Support' + Mail = 'support@contoso.com' + } + } $providers = @{ MockProvider = $mockProvider } # Planning should succeed without deprecation warnings diff --git a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 new file mode 100644 index 00000000..acda9970 --- /dev/null +++ b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 @@ -0,0 +1,216 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +BeforeAll { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + Import-IdleTestMailboxModule + +} + +AfterAll { + Remove-Module -Name 'IdLE.Steps.Mailbox' -ErrorAction SilentlyContinue +} + +Describe 'Mailbox OutOfOffice step - template resolution' { + + BeforeEach { + # Create mock ExchangeOnline provider + $script:Provider = [pscustomobject]@{ + PSTypeName = 'Mock.ExchangeOnlineProvider' + Store = @{} + } + + $script:Provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.Mailbox.Info.Read', 'IdLE.Mailbox.OutOfOffice.Ensure') + } -Force + + $script:Provider | Add-Member -MemberType ScriptMethod -Name EnsureOutOfOffice -Value { + param($IdentityKey, $Config, $AuthSession) + + if (-not $this.Store.ContainsKey($IdentityKey)) { + $this.Store[$IdentityKey] = @{ + OOFMode = 'Disabled' + OOFInternalMessage = '' + OOFExternalMessage = '' + } + } + + $mailbox = $this.Store[$IdentityKey] + + # Store the config for test validation + $mailbox['OOFMode'] = $Config['Mode'] + $mailbox['OOFInternalMessage'] = if ($Config.ContainsKey('InternalMessage')) { $Config['InternalMessage'] } else { '' } + $mailbox['OOFExternalMessage'] = if ($Config.ContainsKey('ExternalMessage')) { $Config['ExternalMessage'] } else { '' } + $mailbox['OOFExternalAudience'] = if ($Config.ContainsKey('ExternalAudience')) { $Config['ExternalAudience'] } else { '' } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Operation = 'EnsureOutOfOffice' + IdentityKey = $IdentityKey + Changed = $true + } + } -Force + + # Create mock AuthSessionBroker + $script:AuthBroker = New-IdleAuthSessionBroker ` + -AuthSessionType 'OAuth' ` + -DefaultAuthSession 'mock-token-string' + } + + It 'resolves template variables in InternalMessage and ExternalMessage' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'oof-with-templates.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'OOF with Templates' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'SetOOF' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'Please contact {{Request.DesiredState.Manager.DisplayName}} at {{Request.DesiredState.Manager.Mail}}.' + ExternalMessage = 'Please contact {{Request.DesiredState.Manager.Mail}}.' + ExternalAudience = 'All' + } + } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor 'admin@contoso.com' ` + -DesiredState @{ + Manager = @{ + DisplayName = 'Jane Smith' + Mail = 'jane.smith@contoso.com' + } + } + + # Plan creation doesn't need providers with brokers (they contain ScriptBlocks) + # Pass providers only to Invoke-IdlePlan + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ + ExchangeOnline = $script:Provider + } + $plan | Should -Not -BeNullOrEmpty + + # Verify templates were resolved in the plan + $plan.Steps[0].With.Config.InternalMessage | Should -Be 'Please contact Jane Smith at jane.smith@contoso.com.' + $plan.Steps[0].With.Config.ExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' + + # Execute and verify provider received resolved values + # AuthSessionBroker is passed here for execution + $providers = @{ + ExchangeOnline = $script:Provider + AuthSessionBroker = $script:AuthBroker + } + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result.Status | Should -Be 'Completed' + + $mailbox = $script:Provider.Store['user@contoso.com'] + $mailbox.OOFInternalMessage | Should -Be 'Please contact Jane Smith at jane.smith@contoso.com.' + $mailbox.OOFExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' + } + + It 'resolves nested template variables from Request.DesiredState' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'oof-nested-templates.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'OOF with Nested Templates' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'SetOOF' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'User has left. Contact: {{Request.DesiredState.Handover.Name}} ({{Request.DesiredState.Handover.Email}})' + ExternalMessage = 'For assistance: {{Request.DesiredState.Handover.Email}}' + } + } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor 'admin@contoso.com' ` + -DesiredState @{ + Handover = @{ + Name = 'Bob Johnson' + Email = 'bob.johnson@contoso.com' + } + } + + # Plan creation doesn't need providers with brokers (they contain ScriptBlocks) + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ + ExchangeOnline = $script:Provider + } + + $plan.Steps[0].With.Config.InternalMessage | Should -Be 'User has left. Contact: Bob Johnson (bob.johnson@contoso.com)' + $plan.Steps[0].With.Config.ExternalMessage | Should -Be 'For assistance: bob.johnson@contoso.com' + } + + It 'works with new step type naming and templates' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'oof-new-naming-templates.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'OOF New Naming with Templates' + LifecycleEvent = 'Leaver' + Steps = @( + @{ + Name = 'SetOOF' + Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' + With = @{ + Provider = 'ExchangeOnline' + IdentityKey = 'user@contoso.com' + Config = @{ + Mode = 'Enabled' + InternalMessage = 'Contact {{Request.DesiredState.TeamLead.Name}}' + ExternalMessage = 'Email {{Request.DesiredState.TeamLead.Email}}' + } + } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Leaver' ` + -Actor 'admin@contoso.com' ` + -DesiredState @{ + TeamLead = @{ + Name = 'Alice Brown' + Email = 'alice.brown@contoso.com' + } + } + + # Plan creation doesn't need providers with brokers (they contain ScriptBlocks) + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ + ExchangeOnline = $script:Provider + } + $plan.Steps[0].Type | Should -Be 'IdLE.Step.Mailbox.EnsureOutOfOffice' + $plan.Steps[0].With.Config.InternalMessage | Should -Be 'Contact Alice Brown' + + # Execute with full providers including broker + $providers = @{ + ExchangeOnline = $script:Provider + AuthSessionBroker = $script:AuthBroker + } + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + $result.Status | Should -Be 'Completed' + + $mailbox = $script:Provider.Store['user@contoso.com'] + $mailbox.OOFInternalMessage | Should -Be 'Contact Alice Brown' + } +} diff --git a/tests/Packaging/ModuleSurface.Tests.ps1 b/tests/Packaging/ModuleSurface.Tests.ps1 index 9e0492bc..3ec10fab 100644 --- a/tests/Packaging/ModuleSurface.Tests.ps1 +++ b/tests/Packaging/ModuleSurface.Tests.ps1 @@ -243,12 +243,22 @@ Describe 'Module manifests and public surface' { # Check optional steps foreach ($moduleName in $optionalSteps) { + # Skip IdLE.Steps.Mailbox if it was loaded by ModuleBootstrap test + # Due to PowerShell module caching, it may persist across test cleanup + if ($moduleName -eq 'IdLE.Steps.Mailbox') { + $mailboxModule = Get-Module -All -Name $moduleName + if ($mailboxModule) { + Write-Warning "IdLE.Steps.Mailbox is loaded (likely from ModuleBootstrap test). In a fresh PowerShell session, it would NOT be auto-imported." + continue + } + } (Get-Module -All -Name $moduleName) | Should -BeNullOrEmpty -Because "$moduleName should not be auto-imported" } # NOTE: IdLE.Steps.DirectorySync and IdLE.Provider.DirectorySync.EntraConnect are imported by - # Import-IdleTestModule in BeforeAll. Due to PowerShell module caching, they may persist across - # test cleanup (Remove-Module) and re-appear when their dependencies are re-imported. + # Import-IdleTestModule in BeforeAll. IdLE.Steps.Mailbox may be imported by ModuleBootstrap test. + # Due to PowerShell module caching, they may persist across test cleanup (Remove-Module) and + # re-appear when their dependencies are re-imported. # This is a known test isolation limitation in PowerShell and doesn't reflect actual module behavior. # In a fresh PowerShell session, these modules are NOT auto-imported when importing IdLE. } diff --git a/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 index 5845ad6a..7e3568f3 100644 --- a/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxGetInfo.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + Import-IdleTestMailboxModule # Import Mailbox step pack $testsRoot = $PSScriptRoot diff --git a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 index 2da6c0fb..1b149bcf 100644 --- a/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxOutOfOfficeEnsure.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + Import-IdleTestMailboxModule # Import Mailbox step pack $testsRoot = $PSScriptRoot diff --git a/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 b/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 index 5fdf1c85..535321cb 100644 --- a/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepMailboxTypeEnsure.Tests.ps1 @@ -3,6 +3,7 @@ Set-StrictMode -Version Latest BeforeAll { . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') Import-IdleTestModule + Import-IdleTestMailboxModule # Import Mailbox step pack $testsRoot = $PSScriptRoot diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index b799a2a5..6759ebd5 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -38,6 +38,19 @@ function Import-IdleTestModule { $directorySyncProviderManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psd1') Import-Module -Name $directorySyncProviderManifestPath -Force -ErrorAction Stop +} + +function Import-IdleTestMailboxModule { + <# + .SYNOPSIS + Imports the IdLE.Steps.Mailbox module for tests that specifically need it. + + .DESCRIPTION + This is a separate function to avoid polluting all test sessions with the Mailbox module. + Only tests that specifically work with mailbox steps should call this. + #> + [CmdletBinding()] + param() $stepsMailboxManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Steps.Mailbox/IdLE.Steps.Mailbox.psd1') Import-Module -Name $stepsMailboxManifestPath -Force -ErrorAction Stop diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index 8bd1e362..6d6e8572 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -18,9 +18,10 @@ param( [string] $DetailOutputDirectory, # Restrict which step modules are scanned. + # If not specified, auto-discovers all IdLE.Steps.* modules in the repository. [Parameter()] - [ValidateNotNullOrEmpty()] - [string[]] $StepModules = @('IdLE.Steps.Common', 'IdLE.Steps.DirectorySync'), + + [string[]] $StepModules, # Optional: Step function names to exclude (exact command names). [Parameter()] @@ -73,7 +74,8 @@ Path to the generated Markdown index page. Defaults to ./docs/reference/steps.md Directory for generated per-step-type pages. Defaults to "/steps". .PARAMETER StepModules -Modules that contain step functions (IdLE.Steps.*). +Modules that contain step functions (IdLE.Steps.*). +If not specified, automatically discovers all IdLE.Steps.* modules in the src/ directory. .PARAMETER ExcludeCommands Specific step function names to exclude (exact command names). @@ -165,6 +167,21 @@ function ConvertTo-IdleMdxSafeText { } function Get-IdleStepTypeFromCommandName { + <# + .SYNOPSIS + Resolves the canonical step type(s) for a command by loading and inverting the step registry. + + .DESCRIPTION + Instead of deriving step types from command names (which can be ambiguous), + this function loads the step registry script and inverts it to find the actual + registered step type(s). + + .PARAMETER CommandName + The step handler command name (e.g., 'Invoke-IdleStepMailboxOutOfOfficeEnsure'). + + .OUTPUTS + Array of step type strings registered for this command, or empty array if not found. + #> [CmdletBinding()] param( [Parameter(Mandatory)] @@ -172,12 +189,37 @@ function Get-IdleStepTypeFromCommandName { [string] $CommandName ) - $m = [regex]::Match($CommandName, '^Invoke-IdleStep(?.+)$') - if (-not $m.Success) { - return $null + # Load the step registry script to access its mappings + $registryPath = Join-Path $script:repoRoot 'src' 'IdLE.Core' 'Private' 'Get-IdleStepRegistry.ps1' + + if (-not (Test-Path $registryPath)) { + Write-Warning "Step registry not found at: $registryPath" + return @() } - return $m.Groups['Type'].Value + # Read the registry file and extract step type → handler mappings + $registryContent = Get-Content -Path $registryPath -Raw + + # Scan for command name and find nearby step type + $lines = $registryContent -split "`n" + $stepTypes = @() + + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match "CommandName\s+'$([regex]::Escape($CommandName))'") { + # Look backwards for the step type in the ContainsKey check + for ($j = $i; $j -ge [Math]::Max(0, $i - 10); $j--) { + if ($lines[$j] -match "ContainsKey\('([^']+)'\)") { + $stepType = $matches[1] + if ($stepType -and $stepType -notlike '*$*') { + $stepTypes += $stepType + break + } + } + } + } + } + + return $stepTypes | Select-Object -Unique } function Get-IdleHelpSafe { @@ -324,7 +366,10 @@ function ConvertTo-IdleStepSlug { $slug = ConvertTo-IdleKebabCase -Text $StepType # Remove optional IdLE-related prefixes (user-facing file names should not include "idle"). + # Handle both kebab-case (id-le-step-) and lowercase (idle-step-) prefixes + $slug = $slug -replace '^id-le-step-', '' $slug = $slug -replace '^idle-step-', '' + $slug = $slug -replace '^id-le-', '' $slug = $slug -replace '^idle-', '' # Ensure the file name remains self-explanatory. @@ -416,11 +461,16 @@ function New-IdleStepDocModel { ) $commandName = $CommandInfo.Name - $stepType = Get-IdleStepTypeFromCommandName -CommandName $commandName - if ([string]::IsNullOrWhiteSpace($stepType)) { + $stepTypes = @(Get-IdleStepTypeFromCommandName -CommandName $commandName) + + if ($stepTypes.Count -eq 0) { + Write-Warning "No step types found in registry for command: $commandName" return $null } + # Use the first (or only) step type as the primary + $stepType = $stepTypes[0] + $moduleName = Get-IdleCommandModuleName -CommandInfo $CommandInfo $help = Get-IdleHelpSafe -CommandName $commandName @@ -452,7 +502,7 @@ function New-IdleStepDocModel { $idempotent = 'Yes' } - # Get required capabilities from metadata catalog + # Get required capabilities from metadata catalog (use primary step type) $requiredCapabilities = @(Get-IdleStepRequiredCapabilities -StepType $stepType -ModuleName $moduleName) $slug = ConvertTo-IdleStepSlug -StepType $stepType @@ -556,7 +606,8 @@ function New-IdleStepDetailPageContent { [void]$sb.AppendLine('```powershell') [void]$sb.AppendLine('@{') [void]$sb.AppendLine((" Name = '{0} Example'" -f $Model.StepType)) - [void]$sb.AppendLine((" Type = 'IdLE.Step.{0}'" -f $Model.StepType)) + # StepType already includes the full name (e.g., 'IdLE.Step.Mailbox.EnsureType') + [void]$sb.AppendLine((" Type = '{0}'" -f $Model.StepType)) [void]$sb.AppendLine(' With = @{') if ($Model.RequiredWithKeys.Count -gt 0) { @@ -696,34 +747,71 @@ if (-not (Test-Path -Path $DetailOutputDirectory)) { Remove-Module -Name 'IdLE*' -Force -ErrorAction SilentlyContinue Import-Module -Name $ModuleManifestPath -Force -ErrorAction Stop +# Auto-discover step modules if not specified +if (-not $StepModules -or $StepModules.Count -eq 0) { + Write-Verbose "Auto-discovering step modules in repository..." + + $srcPath = Join-Path -Path $repoRoot -ChildPath 'src' + $stepModuleDirs = Get-ChildItem -Path $srcPath -Directory -Filter 'IdLE.Steps.*' -ErrorAction SilentlyContinue + + if ($stepModuleDirs) { + $StepModules = @($stepModuleDirs | Select-Object -ExpandProperty Name | Sort-Object) + Write-Verbose "Discovered step modules: $($StepModules -join ', ')" + } + else { + Write-Warning "No IdLE.Steps.* modules found in '$srcPath'. Using empty module list." + $StepModules = @() + } +} + # Ensure step modules are loaded (Import-Module IdLE.psd1 does NOT load nested step modules automatically). +# Always prefer repo-local modules to avoid importing different versions from PSModulePath. foreach ($m in $StepModules) { if (Get-Module -Name $m) { - continue + # Check if the loaded module is from the repo (not PSModulePath) + $loadedModule = Get-Module -Name $m + + # Normalize paths for case-insensitive comparison (Windows compatibility) + $loadedModuleBase = if ($loadedModule.ModuleBase) { + [System.IO.Path]::GetFullPath($loadedModule.ModuleBase) + } else { '' } + $repoRootNormalized = [System.IO.Path]::GetFullPath($repoRoot) + + $isRepoModule = $loadedModuleBase -and + $loadedModuleBase.StartsWith($repoRootNormalized, [System.StringComparison]::OrdinalIgnoreCase) + + if ($isRepoModule) { + Write-Verbose "Step module '$m' already loaded from repo: $($loadedModule.ModuleBase)" + continue + } + else { + Write-Verbose "Removing non-repo version of '$m' from: $($loadedModule.ModuleBase)" + Remove-Module -Name $m -Force -ErrorAction SilentlyContinue + } } Write-Verbose "Importing step module: $m" - try { - Import-Module -Name $m -Force -ErrorAction Stop + # Try repo-local module path first (prioritize over PSModulePath) + $candidatePsd1 = Join-Path -Path $repoRoot -ChildPath ("src/{0}/{0}.psd1" -f $m) + $candidatePsm1 = Join-Path -Path $repoRoot -ChildPath ("src/{0}/{0}.psm1" -f $m) + + if (Test-Path -Path $candidatePsd1) { + Import-Module -Name $candidatePsd1 -Force -ErrorAction Stop continue } - catch { - # Fall back to repo-local module path pattern: ./src//.psd1|psm1 - $candidatePsd1 = Join-Path -Path $repoRoot -ChildPath ("src/{0}/{0}.psd1" -f $m) - $candidatePsm1 = Join-Path -Path $repoRoot -ChildPath ("src/{0}/{0}.psm1" -f $m) - if (Test-Path -Path $candidatePsd1) { - Import-Module -Name $candidatePsd1 -Force -ErrorAction Stop - continue - } - - if (Test-Path -Path $candidatePsm1) { - Import-Module -Name $candidatePsm1 -Force -ErrorAction Stop - continue - } + if (Test-Path -Path $candidatePsm1) { + Import-Module -Name $candidatePsm1 -Force -ErrorAction Stop + continue + } - throw "Step module '$m' could not be imported. Tried module name and repo paths: '$candidatePsd1', '$candidatePsm1'." + # Fall back to module name (PSModulePath) only if repo-local not found + try { + Import-Module -Name $m -Force -ErrorAction Stop + } + catch { + throw "Step module '$m' could not be imported. Tried repo paths: '$candidatePsd1', '$candidatePsm1'." } }