diff --git a/docs/reference/providers/provider-directorysync-entraconnect.md b/docs/reference/providers/provider-directorysync-entraconnect.md index f4a4ecb5..568d48f7 100644 --- a/docs/reference/providers/provider-directorysync-entraconnect.md +++ b/docs/reference/providers/provider-directorysync-entraconnect.md @@ -11,7 +11,7 @@ import EntraConnectTriggerSync from '@site/../examples/workflows/templates/direc - **Module:** `IdLE.Provider.DirectorySync.EntraConnect` - **What it’s for:** Triggering and monitoring **Entra Connect (ADSync)** sync cycles on an on-prem server -- **Execution model:** Remote execution via a host-provided AuthSession (elevated context) +- **Execution model:** Remote execution via provider-managed PSRemoting using a host-provided credential ## When to use @@ -35,7 +35,7 @@ Non-goals: ### Requirements - An Entra Connect (Azure AD Connect) server with ADSync installed (ADSync cmdlets available) -- A host/runtime that can provide an **elevated remote execution handle** to IdLE via AuthSessionBroker +- A host/runtime that can provide an **elevated credential** to IdLE via AuthSessionBroker - Rights to run `Start-ADSyncSyncCycle` and `Get-ADSyncScheduler` in that remote context ### Install (PowerShell Gallery) @@ -66,52 +66,58 @@ $providers = @{ } ``` -## Authentication (important) +## Authentication -This provider requires an AuthSession that supports remote execution and **must be elevated**. +This provider requires an AuthSession credential ([PSCredential]) and **must be elevated**. +The provider creates and cleans up PSRemoting sessions internally. -The AuthSession object must provide a method: +There is no integrated/run-as authentication fallback; a credential-backed AuthSession must be supplied at runtime via the AuthSessionBroker. +To select the runtime credential for this provider, pass the AuthSession via step configuration: -- `InvokeCommand(CommandName, Parameters)` +- With.AuthSessionName +- With.AuthSessionOptions (optional) -Your host/runtime should provide this session via the AuthSessionBroker and you reference it in the step via: +> Keep credentials/secrets out of workflow files. Use the broker/host to resolve them at runtime. -- `AuthSessionName = 'EntraConnect'` -- `AuthSessionOptions = @{ Role = 'EntraConnectAdmin' }` (optional routing key) +## Supported Step Types -> No interactive prompts are made. If the remote context is not elevated, triggering a sync cycle will fail with a privilege/elevation error. +The Directory Sync (Entra Connect) provider supports the directory sync step types listed below: -## Supported operations - -This provider advertises these capabilities: - -- `IdLE.DirectorySync.Trigger` -- `IdLE.DirectorySync.Status` - -Those are typically used by step types like: - -- `IdLE.Step.TriggerDirectorySync` (trigger + optional wait/poll) +| Step type | Typical use | Notes | +| --- | --- | --- | +| `IdLE.Step.TriggerDirectorySync` | Trigger Directory Sync | Executed via a provider-managed PSRemoting session, with optional wait/poll | ## Context Resolvers This provider does **not** support any of the allowlisted Context Resolver capabilities. -Context Resolvers can only use read-only capabilities like `IdLE.Identity.Read` and `IdLE.Entitlement.List`. -This provider does not advertise these capabilities, so it cannot be used in the workflow `ContextResolvers` section. - ## Configuration -This provider has no admin-facing option bag. Configuration is done through: -- step inputs (`PolicyType`, `Wait`, `TimeoutSeconds`, `PollIntervalSeconds`) -- host configuration (remote connection and elevation) +This provider does **not** expose an admin-facing provider option bag. +Configuration for triggering and monitoring sync is supplied through the +`IdLE.Step.TriggerDirectorySync` step inputs via `With.*` keys. + +The generic step schema does not require any `With.*` keys at schema level for this +step type. However, this provider requires specific inputs during provider validation +and execution, as noted below. + +### Step input reference + +| Step input | Type | Default | Meaning | +| --- | --- | --- | --- | +| `With.ComputerName` | `string` | Required by provider | ComputerName for PSSession connection | +| `With.PolicyType` | `string` | Required by provider | `Delta` or `Initial` sync policy | +| `With.Wait` | `bool` | `false` | Poll sync status and wait for result (or timeout) | +| `With.PollIntervalSeconds` | `int` | `10` | Interval in seconds to poll for sync status | +| `With.TimeoutSeconds` | `int` | `600` | Timeout for poll wait in seconds. Will result in `StepFailed` | -## Examples (canonical template) +## Examples {EntraConnectTriggerSync} ## Troubleshooting -- **“Missing privileges or elevation”**: your AuthSession must run commands in an elevated context on the Entra Connect server. -- **“AuthSession must implement InvokeCommand”**: your host must provide an AuthSession object with an `InvokeCommand()` method. -- **Get-ADSyncScheduler not found**: ensure ADSync cmdlets are available in the remote session (module installed/accessible). +- **“Missing privileges or elevation”**: ensure the provided credential is elevated on the Entra Connect server. +- **“AuthSession must be a [PSCredential]”**: configure the AuthSessionBroker/host runtime to provide a credential-backed AuthSession ([PSCredential]) for this provider. +- **Get-ADSyncScheduler not found**: ensure ADSync cmdlets are available on the target server. - **Timeout waiting for completion**: increase `TimeoutSeconds` or check the scheduler state on the server. diff --git a/docs/reference/steps/step-emit-event.md b/docs/reference/steps/step-emit-event.md index 6bb78348..bd882516 100644 --- a/docs/reference/steps/step-emit-event.md +++ b/docs/reference/steps/step-emit-event.md @@ -22,8 +22,8 @@ to write structured events. ## Inputs (With.*) -The required input keys could not be detected automatically. -Please refer to the step description and examples for usage details. +This step has no required ``With.*`` keys at step schema level. +Inputs may still be provider-specific; refer to the step description and examples for usage details. ## Example diff --git a/docs/reference/steps/step-prune-entitlements-ensure-keep.md b/docs/reference/steps/step-prune-entitlements-ensure-keep.md index 580e1421..047346c6 100644 --- a/docs/reference/steps/step-prune-entitlements-ensure-keep.md +++ b/docs/reference/steps/step-prune-entitlements-ensure-keep.md @@ -77,8 +77,8 @@ Authentication: ## Inputs (With.*) -The required input keys could not be detected automatically. -Please refer to the step description and examples for usage details. +This step has no required ``With.*`` keys at step schema level. +Inputs may still be provider-specific; refer to the step description and examples for usage details. ## Example diff --git a/docs/reference/steps/step-trigger-directory-sync.md b/docs/reference/steps/step-trigger-directory-sync.md index f4797ae9..da98cdca 100644 --- a/docs/reference/steps/step-trigger-directory-sync.md +++ b/docs/reference/steps/step-trigger-directory-sync.md @@ -19,29 +19,29 @@ Triggers a directory sync cycle and optionally waits for completion. The host must supply a provider instance via Context.Providers[<ProviderAlias>] that implements: -- StartSyncCycle(PolicyType, AuthSession) +- StartSyncCycle(PolicyType, ComputerName, AuthSession) -- GetSyncCycleState(AuthSession) +- GetSyncCycleState(ComputerName, AuthSession) The step is designed for remote execution and requires an elevated auth session provided by the host's AuthSessionBroker. Authentication: -- With.AuthSessionName (required): routing key for AuthSessionBroker +- With.AuthSessionName (optional): routing key for AuthSessionBroker - With.AuthSessionOptions (optional, hashtable): forwarded to broker for session selection +- If AuthSessionName is omitted, the broker is asked for a default session + +- ComputerName and PolicyType are provider-specific inputs and are validated by the selected provider + - ScriptBlocks in AuthSessionOptions are rejected (security boundary) ## Inputs (With.*) -The following keys are required in the step's ``With`` configuration: - -| Key | Required | Description | -| --- | --- | --- | -| `AuthSessionName` | Yes | Name of auth session to use (optional) | -| `PolicyType` | Yes | Type of policy (e.g., Delta, Initial) | +This step has no required ``With.*`` keys at step schema level. +Inputs may still be provider-specific; refer to the step description and examples for usage details. ## Example @@ -51,6 +51,7 @@ $step = @{ Type = 'IdLE.Step.TriggerDirectorySync' With = @{ AuthSessionName = 'DirectorySync' + ComputerName = 'ad-sync1.corp.local' PolicyType = 'Delta' Wait = $true } diff --git a/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 b/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 index 9e0d2711..5a9c9c4f 100644 --- a/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 +++ b/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 @@ -30,6 +30,7 @@ AuthSessionOptions = @{ Role = 'EntraConnectAdmin' } + ComputerName = '{{Request.Intent.EntraConnectServer}}' PolicyType = 'Delta' Wait = $true diff --git a/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 b/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 index a8470938..6c06cf9c 100644 --- a/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 +++ b/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 @@ -10,11 +10,12 @@ With = @{ Provider = 'DirectorySync' - # Auth session is provided by the host (remote execution handle). + # Auth session is provided by the host (credential), with an optional routing key. AuthSessionName = 'EntraConnect' AuthSessionOptions = @{ Role = 'EntraConnectAdmin' } + ComputerName = '{{Request.Intent.ComputerName}}' # Delta or Initial PolicyType = '{{Request.Intent.PolicyType}}' @@ -34,4 +35,4 @@ } } ) -} \ No newline at end of file +} diff --git a/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 b/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 index 297bb91c..68e90a6f 100644 --- a/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 +++ b/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 @@ -7,16 +7,15 @@ function New-IdleEntraConnectDirectorySyncProvider { This provider triggers and monitors Entra ID Connect (ADSync) sync cycles on an on-premises server via remote execution. - The provider uses an AuthSession object (remote execution handle) provided by the host. - The AuthSession must implement InvokeCommand(CommandName, Parameters) to execute - commands in an elevated/privileged context on the Entra Connect server. + The provider uses a credential AuthSession provided by the host and establishes + a PSRemoting session to the target Entra Connect server internally. No interactive prompts are made; elevation and authentication are the host's responsibility via the AuthSessionBroker. .OUTPUTS PSCustomObject - Provider instance with methods: GetCapabilities(), StartSyncCycle(PolicyType, AuthSession), GetSyncCycleState(AuthSession) + Provider instance with methods: GetCapabilities(), StartSyncCycle(PolicyType, ComputerName, AuthSession), GetSyncCycleState(ComputerName, AuthSession) .EXAMPLE $provider = New-IdleEntraConnectDirectorySyncProvider @@ -24,15 +23,10 @@ function New-IdleEntraConnectDirectorySyncProvider { # Returns: @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') .EXAMPLE - # With a mock remote execution handle - $mockAuthSession = [pscustomobject]@{ - InvokeCommand = { param($CommandName, $Parameters) - # Mock implementation - return @{ Started = $true } - } - } + # With a credential from AuthSessionBroker (AuthSessionType='Credential') + $credential = Get-Credential $provider = New-IdleEntraConnectDirectorySyncProvider - $result = $provider.StartSyncCycle('Delta', $mockAuthSession) + $result = $provider.StartSyncCycle('Delta', 'ad-sync1.corp.local', $credential) #> [CmdletBinding()] param() @@ -58,6 +52,60 @@ function New-IdleEntraConnectDirectorySyncProvider { ) } -Force + $provider | Add-Member -MemberType ScriptMethod -Name NewRemoteSession -Value { + param( + [Parameter(Mandatory)] + [string] $ComputerName, + + [Parameter(Mandatory)] + [pscredential] $Credential + ) + + try { + return New-PSSession -ComputerName $ComputerName -Credential $Credential -ErrorAction Stop + } + catch { + throw "Failed to establish PSRemoting session to '$ComputerName': $($_.Exception.Message)" + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name InvokeRemoteCommand -Value { + param( + [Parameter(Mandatory)] + [object] $Session, + + [Parameter(Mandatory)] + [scriptblock] $ScriptBlock, + + [Parameter()] + [AllowNull()] + [object[]] $ArgumentList + ) + + if ($null -eq $ArgumentList -or $ArgumentList.Count -eq 0) { + return Invoke-Command -Session $Session -ScriptBlock $ScriptBlock -ErrorAction Stop + } + + return Invoke-Command -Session $Session -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name RemoveRemoteSession -Value { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Session + ) + + if ($null -ne $Session) { + try { + Remove-PSSession -Session $Session -ErrorAction Stop + } + catch { + Write-Warning "Failed to remove PSRemoting session: $($_.Exception.Message)" + } + } + } -Force + $provider | Add-Member -MemberType ScriptMethod -Name StartSyncCycle -Value { <# .SYNOPSIS @@ -69,9 +117,11 @@ function New-IdleEntraConnectDirectorySyncProvider { .PARAMETER PolicyType The sync policy type: 'Delta' or 'Initial'. + .PARAMETER ComputerName + Target Entra Connect server hostname for PSRemoting. + .PARAMETER AuthSession - Remote execution handle provided by the host's AuthSessionBroker. - Must implement InvokeCommand(CommandName, Parameters). + Credential ([PSCredential]) provided by the host's AuthSessionBroker. .OUTPUTS PSCustomObject with properties: @@ -83,29 +133,34 @@ function New-IdleEntraConnectDirectorySyncProvider { [ValidateSet('Delta', 'Initial', IgnoreCase = $true)] [string] $PolicyType, + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string] $ComputerName, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $AuthSession ) - # Validate AuthSession contract - if ($null -eq $AuthSession.PSObject.Methods['InvokeCommand']) { - throw "AuthSession must implement InvokeCommand(CommandName, Parameters) method. " + ` - "The host must provide an elevated remote session via AuthSessionBroker." + if ($AuthSession -isnot [pscredential]) { + $actualType = $AuthSession.GetType().FullName + throw "AuthSession must be a [PSCredential] for PSRemoting session creation. Received: [$actualType]" } + $remoteSession = $null try { - # Execute Start-ADSyncSyncCycle remotely - # The remote session should already have ADSync module available or will import it - $AuthSession.InvokeCommand('Start-ADSyncSyncCycle', @{ - PolicyType = $PolicyType - }) | Out-Null - - # Start-ADSyncSyncCycle returns a result object or throws on error - # Success case: return Started = true + $remoteSession = $this.NewRemoteSession($ComputerName, $AuthSession) + + $this.InvokeRemoteCommand($remoteSession, { + param([string] $RemotePolicyType) + Import-Module -Name ADSync -ErrorAction Stop + Start-ADSyncSyncCycle -PolicyType $RemotePolicyType -ErrorAction Stop + }, @($PolicyType)) | Out-Null + return [pscustomobject]@{ Started = $true - Message = "Sync cycle triggered with PolicyType: $PolicyType" + Message = "Sync cycle triggered with PolicyType: $PolicyType on $ComputerName" } } catch { @@ -117,9 +172,11 @@ function New-IdleEntraConnectDirectorySyncProvider { "The AuthSession must provide an elevated execution context. Original error: $errorMessage" } - # Re-throw other errors throw "Failed to start sync cycle: $errorMessage" } + finally { + $this.RemoveRemoteSession($remoteSession) + } } -Force $provider | Add-Member -MemberType ScriptMethod -Name GetSyncCycleState -Value { @@ -131,9 +188,11 @@ function New-IdleEntraConnectDirectorySyncProvider { Queries the sync scheduler state via Get-ADSyncScheduler to determine if a sync cycle is currently in progress. + .PARAMETER ComputerName + Target Entra Connect server hostname for PSRemoting. + .PARAMETER AuthSession - Remote execution handle provided by the host's AuthSessionBroker. - Must implement InvokeCommand(CommandName, Parameters). + Credential ([PSCredential]) provided by the host's AuthSessionBroker. .OUTPUTS PSCustomObject with properties: @@ -142,20 +201,29 @@ function New-IdleEntraConnectDirectorySyncProvider { - Details (hashtable, optional): additional state information #> param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) })] + [string] $ComputerName, + [Parameter(Mandatory)] [ValidateNotNull()] [object] $AuthSession ) - # Validate AuthSession contract - if ($null -eq $AuthSession.PSObject.Methods['InvokeCommand']) { - throw "AuthSession must implement InvokeCommand(CommandName, Parameters) method. " + ` - "The host must provide an elevated remote session via AuthSessionBroker." + if ($AuthSession -isnot [pscredential]) { + $actualType = $AuthSession.GetType().FullName + throw "AuthSession must be a [PSCredential] for PSRemoting session creation. Received: [$actualType]" } + $remoteSession = $null try { - # Execute Get-ADSyncScheduler remotely - $scheduler = $AuthSession.InvokeCommand('Get-ADSyncScheduler', @{}) + $remoteSession = $this.NewRemoteSession($ComputerName, $AuthSession) + + $scheduler = $this.InvokeRemoteCommand($remoteSession, { + Import-Module -Name ADSync -ErrorAction Stop + Get-ADSyncScheduler -ErrorAction Stop + }, @()) # Determine if sync is in progress # Get-ADSyncScheduler returns an object with SyncCycleInProgress property @@ -195,6 +263,9 @@ function New-IdleEntraConnectDirectorySyncProvider { throw "Failed to get sync cycle state: $errorMessage" } + finally { + $this.RemoveRemoteSession($remoteSession) + } } -Force return $provider diff --git a/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 b/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 index b1733cb1..cac31134 100644 --- a/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 +++ b/src/IdLE.Steps.DirectorySync/Public/Invoke-IdleStepTriggerDirectorySync.ps1 @@ -6,15 +6,17 @@ function Invoke-IdleStepTriggerDirectorySync { .DESCRIPTION This is a provider-agnostic step. The host must supply a provider instance via Context.Providers[] that implements: - - StartSyncCycle(PolicyType, AuthSession) - - GetSyncCycleState(AuthSession) + - StartSyncCycle(PolicyType, ComputerName, AuthSession) + - GetSyncCycleState(ComputerName, AuthSession) The step is designed for remote execution and requires an elevated auth session provided by the host's AuthSessionBroker. Authentication: - - With.AuthSessionName (required): routing key for AuthSessionBroker + - With.AuthSessionName (optional): routing key for AuthSessionBroker - With.AuthSessionOptions (optional, hashtable): forwarded to broker for session selection + - If AuthSessionName is omitted, the broker is asked for a default session + - ComputerName and PolicyType are provider-specific inputs and are validated by the selected provider - ScriptBlocks in AuthSessionOptions are rejected (security boundary) .PARAMETER Context @@ -22,8 +24,9 @@ function Invoke-IdleStepTriggerDirectorySync { .PARAMETER Step Normalized step object from the plan. Must contain a 'With' hashtable with keys: - - AuthSessionName (required, string): auth session name for broker - - PolicyType (required, string): 'Delta' or 'Initial' (case-insensitive) + - AuthSessionName (optional, string): auth session name for broker (default session is used when omitted) + - ComputerName (optional, string): provider-specific target server input + - PolicyType (optional, string): provider-specific policy input - Provider (optional, string): provider alias, defaults to 'DirectorySync' - Wait (optional, bool): wait for cycle completion, defaults to $false - TimeoutSeconds (optional, int): wait timeout, defaults to 600 @@ -39,6 +42,7 @@ function Invoke-IdleStepTriggerDirectorySync { Type = 'IdLE.Step.TriggerDirectorySync' With = @{ AuthSessionName = 'DirectorySync' + ComputerName = 'ad-sync1.corp.local' PolicyType = 'Delta' Wait = $true } @@ -60,19 +64,8 @@ function Invoke-IdleStepTriggerDirectorySync { throw "TriggerDirectorySync requires 'With' to be a hashtable." } - # Validate required inputs - if (-not $with.ContainsKey('AuthSessionName')) { - throw "TriggerDirectorySync requires With.AuthSessionName." - } - - if (-not $with.ContainsKey('PolicyType')) { - throw "TriggerDirectorySync requires With.PolicyType." - } - - $policyType = [string]$with.PolicyType - if ($policyType -notin @('Delta', 'Initial')) { - throw "TriggerDirectorySync: With.PolicyType must be 'Delta' or 'Initial' (case-insensitive). Got: $policyType" - } + $policyType = if ($with.ContainsKey('PolicyType')) { [string]$with.PolicyType } else { $null } + $computerName = if ($with.ContainsKey('ComputerName')) { [string]$with.ComputerName } else { $null } # Optional inputs with defaults $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'DirectorySync' } @@ -103,8 +96,17 @@ function Invoke-IdleStepTriggerDirectorySync { try { # Trigger sync cycle - $Context.EventSink.WriteEvent('DirectorySyncTriggered', "Triggering $policyType sync cycle", $stepName, @{ + $policyTypeText = [string]$policyType + $triggerMessage = if ([string]::IsNullOrWhiteSpace($policyTypeText)) { + 'Triggering directory sync cycle' + } + else { + "Triggering $policyTypeText sync cycle" + } + + $Context.EventSink.WriteEvent('DirectorySyncTriggered', $triggerMessage, $stepName, @{ PolicyType = $policyType + ComputerName = $computerName }) $startResult = Invoke-IdleProviderMethod ` @@ -112,7 +114,7 @@ function Invoke-IdleStepTriggerDirectorySync { -With $with ` -ProviderAlias $providerAlias ` -MethodName 'StartSyncCycle' ` - -MethodArguments @($policyType) + -MethodArguments @($policyType, $computerName) $changed = $false if ($null -ne $startResult -and ($startResult.PSObject.Properties.Name -contains 'Started')) { @@ -140,7 +142,7 @@ function Invoke-IdleStepTriggerDirectorySync { -With $with ` -ProviderAlias $providerAlias ` -MethodName 'GetSyncCycleState' ` - -MethodArguments @() + -MethodArguments @($computerName) $lastState = if ($null -ne $stateResult) { $stateResult.State } else { 'Unknown' } @@ -159,7 +161,7 @@ function Invoke-IdleStepTriggerDirectorySync { -With $with ` -ProviderAlias $providerAlias ` -MethodName 'GetSyncCycleState' ` - -MethodArguments @() + -MethodArguments @($computerName) $inProgress = $true if ($null -ne $stateResult -and ($stateResult.PSObject.Properties.Name -contains 'InProgress')) { diff --git a/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 b/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 index 5a0720e0..54109194 100644 --- a/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 +++ b/src/IdLE.Steps.DirectorySync/StepMetadataCatalog.psd1 @@ -15,8 +15,8 @@ 'IdLE.Step.TriggerDirectorySync' = @{ RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') WithSchema = @{ - RequiredKeys = @('AuthSessionName', 'PolicyType') - OptionalKeys = @('Provider', 'Wait', 'TimeoutSeconds', 'PollIntervalSeconds', 'AuthSessionOptions') + RequiredKeys = @() + OptionalKeys = @('Provider', 'AuthSessionName', 'AuthSessionOptions', 'ComputerName', 'PolicyType', 'Wait', 'TimeoutSeconds', 'PollIntervalSeconds') } } } diff --git a/tests/ProviderContracts/DirectorySyncProviderComputerNameCredential.Contract.ps1 b/tests/ProviderContracts/DirectorySyncProviderComputerNameCredential.Contract.ps1 new file mode 100644 index 00000000..77f5076d --- /dev/null +++ b/tests/ProviderContracts/DirectorySyncProviderComputerNameCredential.Contract.ps1 @@ -0,0 +1,83 @@ +Set-StrictMode -Version Latest + +function Invoke-IdleDirectorySyncProviderComputerNameCredentialContractTests { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [scriptblock] $ProviderFactory + ) + + $cases = @( + @{ + ProviderFactory = $ProviderFactory + } + ) + + Context 'ComputerName + Credential contract' -ForEach $cases { + BeforeEach { + $providerFactory = $_.ProviderFactory + if ($providerFactory -is [scriptblock]) { + $script:Provider = & $providerFactory + } + elseif ($providerFactory -is [string]) { + $script:Provider = & (Get-Command -Name $providerFactory -ErrorAction Stop) + } + else { + throw "ProviderFactory must be a scriptblock or command name string. Got: $($providerFactory.GetType().FullName)" + } + if ($null -eq $script:Provider) { + throw 'ProviderFactory returned $null. A provider instance is required for contract tests.' + } + + $script:Credential = [PSCredential]::new( + 'contoso\syncadmin', + (ConvertTo-SecureString -String 'P@ssw0rd!' -AsPlainText -Force) + ) + + $script:MockSession = [pscustomobject]@{ + Id = 1 + } + $script:Provider | Add-Member -NotePropertyName LastComputerName -NotePropertyValue $null -Force + $script:Provider | Add-Member -NotePropertyName LastCredential -NotePropertyValue $null -Force + $script:Provider | Add-Member -NotePropertyName RemovedSession -NotePropertyValue $null -Force + + $script:Provider | Add-Member -MemberType ScriptMethod -Name NewRemoteSession -Value { + param([string] $ComputerName, [pscredential] $Credential) + $this.LastComputerName = $ComputerName + $this.LastCredential = $Credential + return $script:MockSession + } -Force + + $script:Provider | Add-Member -MemberType ScriptMethod -Name InvokeRemoteCommand -Value { + param([object] $Session, [scriptblock] $ScriptBlock, [object[]] $ArgumentList) + return [pscustomobject]@{ + SyncCycleInProgress = $false + } + } -Force + + $script:Provider | Add-Member -MemberType ScriptMethod -Name RemoveRemoteSession -Value { + param([object] $Session) + $this.RemovedSession = $Session + } -Force + } + + It 'StartSyncCycle accepts ComputerName and Credential auth session' { + $result = $script:Provider.StartSyncCycle('Delta', 'ad-sync1.corp.local', $script:Credential) + + $result.Started | Should -BeTrue + $script:Provider.LastComputerName | Should -Be 'ad-sync1.corp.local' + $script:Provider.LastCredential | Should -Be $script:Credential + $script:Provider.RemovedSession | Should -Be $script:MockSession + } + + It 'GetSyncCycleState accepts ComputerName and Credential auth session' { + $result = $script:Provider.GetSyncCycleState('ad-sync1.corp.local', $script:Credential) + + $result.InProgress | Should -BeFalse + $script:Provider.LastComputerName | Should -Be 'ad-sync1.corp.local' + $script:Provider.LastCredential | Should -Be $script:Credential + $script:Provider.RemovedSession | Should -Be $script:MockSession + } + } +} diff --git a/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 b/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 index 42a24090..7e83912e 100644 --- a/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 +++ b/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 @@ -14,46 +14,50 @@ BeforeDiscovery { throw "Provider capabilities contract not found at: $capabilitiesContractPath" } . $capabilitiesContractPath + + $computerNameCredentialContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\DirectorySyncProviderComputerNameCredential.Contract.ps1' + if (-not (Test-Path -LiteralPath $computerNameCredentialContractPath -PathType Leaf)) { + throw "Directory sync provider ComputerName+Credential contract not found at: $computerNameCredentialContractPath" + } + . $computerNameCredentialContractPath } Describe 'Entra Connect directory sync provider contracts' { Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { New-IdleEntraConnectDirectorySyncProvider } + Invoke-IdleDirectorySyncProviderComputerNameCredentialContractTests -ProviderFactory { New-IdleEntraConnectDirectorySyncProvider } Context 'Directory sync provider methods' { - BeforeAll { + BeforeEach { $script:Provider = New-IdleEntraConnectDirectorySyncProvider + $script:ComputerName = 'ad-sync1.corp.local' + $script:MockCredential = [PSCredential]::new( + 'contoso\syncadmin', + (ConvertTo-SecureString -String 'P@ssw0rd!' -AsPlainText -Force) + ) + $script:MockSession = [pscustomobject]@{ Id = 1 } + $script:Provider | Add-Member -NotePropertyName LastComputerName -NotePropertyValue $null -Force + $script:Provider | Add-Member -NotePropertyName LastCredential -NotePropertyValue $null -Force + $script:Provider | Add-Member -NotePropertyName RemovedSession -NotePropertyValue $null -Force + + $script:Provider | Add-Member -MemberType ScriptMethod -Name NewRemoteSession -Value { + param([string] $ComputerName, [pscredential] $Credential) + $this.LastComputerName = $ComputerName + $this.LastCredential = $Credential + return $script:MockSession + } -Force - # Mock AuthSession with InvokeCommand method - $script:MockAuthSession = [pscustomobject]@{ - PSTypeName = 'Mock.AuthSession' - } - - $script:MockAuthSession | Add-Member -MemberType ScriptMethod -Name InvokeCommand -Value { - param( - [Parameter(Mandatory)] - [string] $CommandName, - - [Parameter(Mandatory)] - [hashtable] $Parameters - ) - - # Mock behavior for Start-ADSyncSyncCycle - if ($CommandName -eq 'Start-ADSyncSyncCycle') { - return [pscustomobject]@{ - Result = 'Success' - } - } - - # Mock behavior for Get-ADSyncScheduler - if ($CommandName -eq 'Get-ADSyncScheduler') { - return [pscustomobject]@{ - SyncCycleInProgress = $false - AllowedSyncCycleInterval = '00:30:00' - NextSyncCyclePolicyType = 'Delta' - } + $script:Provider | Add-Member -MemberType ScriptMethod -Name InvokeRemoteCommand -Value { + param([object] $Session, [scriptblock] $ScriptBlock, [object[]] $ArgumentList) + return [pscustomobject]@{ + SyncCycleInProgress = $false + AllowedSyncCycleInterval = '00:30:00' + NextSyncCyclePolicyType = 'Delta' } + } -Force - throw "Unexpected command: $CommandName" + $script:Provider | Add-Member -MemberType ScriptMethod -Name RemoveRemoteSession -Value { + param([object] $Session) + $this.RemovedSession = $Session } -Force } @@ -61,45 +65,83 @@ Describe 'Entra Connect directory sync provider contracts' { $script:Provider.PSObject.Methods.Name | Should -Contain 'StartSyncCycle' } - It 'StartSyncCycle accepts PolicyType and AuthSession parameters' { - $result = $script:Provider.StartSyncCycle('Delta', $script:MockAuthSession) + It 'StartSyncCycle accepts PolicyType, ComputerName, and AuthSession parameters' { + $result = $script:Provider.StartSyncCycle('Delta', $script:ComputerName, $script:MockCredential) $result | Should -Not -BeNullOrEmpty $result.PSObject.Properties.Name | Should -Contain 'Started' $result.PSObject.Properties.Name | Should -Contain 'Message' + $script:Provider.LastComputerName | Should -Be $script:ComputerName + $script:Provider.LastCredential | Should -Be $script:MockCredential + $script:Provider.RemovedSession | Should -Be $script:MockSession } It 'StartSyncCycle validates PolicyType' { - { $script:Provider.StartSyncCycle('Invalid', $script:MockAuthSession) } | Should -Throw + { $script:Provider.StartSyncCycle('Invalid', $script:ComputerName, $script:MockCredential) } | Should -Throw + } + + It 'StartSyncCycle validates ComputerName' { + { $script:Provider.StartSyncCycle('Delta', '', $script:MockCredential) } | Should -Throw -ErrorId * -ExpectedMessage '*ComputerName*' } - It 'StartSyncCycle validates AuthSession implements InvokeCommand' { + It 'StartSyncCycle validates AuthSession is PSCredential' { $badSession = [pscustomobject]@{ Name = 'BadSession' } - { $script:Provider.StartSyncCycle('Delta', $badSession) } | Should -Throw -ErrorId * -ExpectedMessage '*InvokeCommand*' + { $script:Provider.StartSyncCycle('Delta', $script:ComputerName, $badSession) } | Should -Throw -ErrorId * -ExpectedMessage '*PSCredential*' + } + + It 'StartSyncCycle always closes remoting session' { + $script:Provider | Add-Member -MemberType ScriptMethod -Name InvokeRemoteCommand -Value { + param([object] $Session, [scriptblock] $ScriptBlock, [object[]] $ArgumentList) + throw 'remote failure' + } -Force + + { $script:Provider.StartSyncCycle('Delta', $script:ComputerName, $script:MockCredential) } | Should -Throw + $script:Provider.RemovedSession | Should -Be $script:MockSession } It 'Exposes GetSyncCycleState method' { $script:Provider.PSObject.Methods.Name | Should -Contain 'GetSyncCycleState' } - It 'GetSyncCycleState accepts AuthSession parameter' { - $result = $script:Provider.GetSyncCycleState($script:MockAuthSession) + It 'GetSyncCycleState accepts ComputerName and AuthSession parameters' { + $result = $script:Provider.GetSyncCycleState($script:ComputerName, $script:MockCredential) $result | Should -Not -BeNullOrEmpty $result.PSObject.Properties.Name | Should -Contain 'InProgress' $result.PSObject.Properties.Name | Should -Contain 'State' $result.PSObject.Properties.Name | Should -Contain 'Details' + $script:Provider.LastComputerName | Should -Be $script:ComputerName + $script:Provider.LastCredential | Should -Be $script:MockCredential + $script:Provider.RemovedSession | Should -Be $script:MockSession } It 'GetSyncCycleState returns correct InProgress value' { - $result = $script:Provider.GetSyncCycleState($script:MockAuthSession) + $result = $script:Provider.GetSyncCycleState($script:ComputerName, $script:MockCredential) $result.InProgress | Should -BeOfType [bool] } - It 'GetSyncCycleState validates AuthSession implements InvokeCommand' { + It 'GetSyncCycleState validates ComputerName' { + { $script:Provider.GetSyncCycleState('', $script:MockCredential) } | Should -Throw -ErrorId * -ExpectedMessage '*ComputerName*' + } + + It 'GetSyncCycleState validates ComputerName whitespace' { + { $script:Provider.GetSyncCycleState(' ', $script:MockCredential) } | Should -Throw -ErrorId * -ExpectedMessage '*ComputerName*' + } + + It 'GetSyncCycleState validates AuthSession is PSCredential' { $badSession = [pscustomobject]@{ Name = 'BadSession' } - { $script:Provider.GetSyncCycleState($badSession) } | Should -Throw -ErrorId * -ExpectedMessage '*InvokeCommand*' + { $script:Provider.GetSyncCycleState($script:ComputerName, $badSession) } | Should -Throw -ErrorId * -ExpectedMessage '*PSCredential*' + } + + It 'GetSyncCycleState always closes remoting session' { + $script:Provider | Add-Member -MemberType ScriptMethod -Name InvokeRemoteCommand -Value { + param([object] $Session, [scriptblock] $ScriptBlock, [object[]] $ArgumentList) + throw 'remote failure' + } -Force + + { $script:Provider.GetSyncCycleState($script:ComputerName, $script:MockCredential) } | Should -Throw + $script:Provider.RemovedSession | Should -Be $script:MockSession } } diff --git a/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 b/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 index d2629c89..be8e4c70 100644 --- a/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 +++ b/tests/Steps/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 @@ -12,6 +12,8 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { PSTypeName = 'Mock.DirectorySyncProvider' Name = 'MockDirectorySyncProvider' PollCount = 0 + LastPolicyType = $null + LastComputerName = $null } $script:MockProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { @@ -23,10 +25,16 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { [Parameter(Mandatory)] [string] $PolicyType, + [Parameter(Mandatory)] + [string] $ComputerName, + [Parameter(Mandatory)] [object] $AuthSession ) + $this.LastPolicyType = $PolicyType + $this.LastComputerName = $ComputerName + return [pscustomobject]@{ Started = $true Message = "Sync cycle triggered with PolicyType: $PolicyType" @@ -35,10 +43,15 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $script:MockProvider | Add-Member -MemberType ScriptMethod -Name GetSyncCycleState -Value { param( + [Parameter(Mandatory)] + [string] $ComputerName, + [Parameter(Mandatory)] [object] $AuthSession ) + $this.LastComputerName = $ComputerName + # Increment poll count and determine state $this.PollCount++ @@ -87,10 +100,18 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { Type = 'IdLE.Step.TriggerDirectorySync' With = @{ AuthSessionName = 'EntraConnect' + ComputerName = 'ad-sync1.corp.local' PolicyType = 'Delta' Provider = 'DirectorySync' } } + + $script:SetPermissiveStartSyncCycle = { + $script:MockProvider | Add-Member -MemberType ScriptMethod -Name StartSyncCycle -Value { + param([AllowNull()][object] $PolicyType, [AllowNull()][object] $ComputerName, [object] $AuthSession) + return [pscustomobject]@{ Started = $true } + } -Force + } } Context 'Input validation' { @@ -104,33 +125,72 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { { & $handler -Context $script:Context -Step $step } | Should -Throw } - It 'throws when With.AuthSessionName is missing' { + It 'does not enforce With.AuthSessionName at step level' { $step = $script:StepTemplate $step.With.Remove('AuthSessionName') $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*AuthSessionName*' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' } - It 'throws when With.PolicyType is missing' { + It 'does not enforce With.PolicyType at step level' { $step = $script:StepTemplate $step.With.Remove('PolicyType') + & $script:SetPermissiveStartSyncCycle $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PolicyType*' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' } - It 'throws when With.PolicyType is invalid' { + It 'passes With.PolicyType = Initial through to the provider path' { + $step = $script:StepTemplate + $step.With.PolicyType = 'Initial' + + $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'throws error when provider rejects invalid With.PolicyType value' { $step = $script:StepTemplate $step.With.PolicyType = 'Invalid' + $script:MockProvider | Add-Member -MemberType ScriptMethod -Name StartSyncCycle -Value { + param( + [Parameter(Mandatory)] + [ValidateSet('Delta', 'Initial')] + [string] $PolicyType, + [Parameter(Mandatory)] + [string] $ComputerName, + [Parameter(Mandatory)] + [object] $AuthSession + ) + return [pscustomobject]@{ Started = $true } + } -Force $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' - { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PolicyType*' + $thrown = $null + try { + $null = & $handler -Context $script:Context -Step $step + } + catch { + $thrown = $_ + } + + $thrown | Should -Not -BeNullOrEmpty + $thrown.Exception.Message | Should -Match 'Exception calling "StartSyncCycle"' + $thrown.Exception.Message | Should -Match 'Invalid' + $thrown.Exception.Message | Should -Not -Match 'TriggerDirectorySync: With.PolicyType' } - It 'accepts Delta as PolicyType' { + It 'does not enforce With.ComputerName at step level' { $step = $script:StepTemplate - $step.With.PolicyType = 'Delta' + $step.With.Remove('ComputerName') + & $script:SetPermissiveStartSyncCycle $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' $result = & $handler -Context $script:Context -Step $step @@ -138,14 +198,28 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $result.Status | Should -Be 'Completed' } - It 'accepts Initial as PolicyType' { + It 'does not enforce With.ComputerName whitespace validation at step level' { $step = $script:StepTemplate - $step.With.PolicyType = 'Initial' + $step.With.ComputerName = ' ' + & $script:SetPermissiveStartSyncCycle + + $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'coerces non-string PolicyType and ComputerName to strings before provider invocation' { + $step = $script:StepTemplate + $step.With.PolicyType = 123 + $step.With.ComputerName = 456 $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' $result = & $handler -Context $script:Context -Step $step $result.Status | Should -Be 'Completed' + $script:MockProvider.LastPolicyType | Should -Be '123' + $script:MockProvider.LastComputerName | Should -Be '456' } It 'uses default provider alias when not specified' { @@ -186,6 +260,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $result.Status | Should -Be 'Completed' $result.Changed | Should -BeTrue $result.Error | Should -BeNullOrEmpty + $script:MockProvider.LastComputerName | Should -Be 'ad-sync1.corp.local' } It 'defaults to not waiting when Wait is not specified' { @@ -214,7 +289,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { It 'throws timeout error when sync does not complete in time' { # Mock provider that never completes $script:MockProvider | Add-Member -MemberType ScriptMethod -Name GetSyncCycleState -Value { - param([object] $AuthSession) + param([string] $ComputerName, [object] $AuthSession) return [pscustomobject]@{ InProgress = $true State = 'InProgress' @@ -235,7 +310,7 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { # Use the provider's PollCount property which is already initialized $script:MockProvider.PollCount = 0 $script:MockProvider | Add-Member -MemberType ScriptMethod -Name GetSyncCycleState -Value { - param([object] $AuthSession) + param([string] $ComputerName, [object] $AuthSession) $this.PollCount++ $inProgress = $this.PollCount -le 2 return [pscustomobject]@{ @@ -288,6 +363,45 @@ Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { $capturedEvents.Type | Should -Contain 'DirectorySyncTriggered' } + It 'emits readable trigger message when With.PolicyType is omitted' { + $capturedEvents = [System.Collections.ArrayList]::new() + $script:Context.EventSink = [pscustomobject]@{} + $script:Context.EventSink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { + param($Type, $Message, $StepName, $Data) + $null = $capturedEvents.Add(@{ Type = $Type; Message = $Message; StepName = $StepName; Data = $Data }) + } -Force + + $step = $script:StepTemplate + $step.With.Remove('PolicyType') + & $script:SetPermissiveStartSyncCycle + + $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' + $null = & $handler -Context $script:Context -Step $step + + $triggerEvent = $capturedEvents | Where-Object { $_.Type -eq 'DirectorySyncTriggered' } | Select-Object -First 1 + $triggerEvent.Message | Should -Be 'Triggering directory sync cycle' + } + + It 'emits string values in DirectorySyncTriggered event data for PolicyType and ComputerName' { + $capturedEvents = [System.Collections.ArrayList]::new() + $script:Context.EventSink = [pscustomobject]@{} + $script:Context.EventSink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { + param($Type, $Message, $StepName, $Data) + $null = $capturedEvents.Add(@{ Type = $Type; Message = $Message; StepName = $StepName; Data = $Data }) + } -Force + + $step = $script:StepTemplate + $step.With.PolicyType = 123 + $step.With.ComputerName = 456 + + $handler = 'IdLE.Steps.DirectorySync\Invoke-IdleStepTriggerDirectorySync' + $null = & $handler -Context $script:Context -Step $step + + $triggerEvent = $capturedEvents | Where-Object { $_.Type -eq 'DirectorySyncTriggered' } | Select-Object -First 1 + $triggerEvent.Data.PolicyType | Should -Be '123' + $triggerEvent.Data.ComputerName | Should -Be '456' + } + It 'emits DirectorySyncCompleted event' { $capturedEvents = [System.Collections.ArrayList]::new() $script:Context.EventSink = [pscustomobject]@{} diff --git a/tests/fixtures/workflows/joiner-with-dirsync.psd1 b/tests/fixtures/workflows/joiner-with-dirsync.psd1 index cf20dbba..404092b5 100644 --- a/tests/fixtures/workflows/joiner-with-dirsync.psd1 +++ b/tests/fixtures/workflows/joiner-with-dirsync.psd1 @@ -9,6 +9,7 @@ Type = 'IdLE.Step.TriggerDirectorySync' With = @{ AuthSessionName = 'DirSync' + ComputerName = 'ad-sync1.corp.local' PolicyType = 'Delta' Wait = $false Provider = 'DirectorySync' diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index 4f289260..3a1b1b56 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -608,8 +608,8 @@ function New-IdleStepDetailPageContent { [void]$sb.AppendLine() if ($Model.RequiredWithKeys.Count -eq 0) { - [void]$sb.AppendLine('The required input keys could not be detected automatically.') - [void]$sb.AppendLine('Please refer to the step description and examples for usage details.') + [void]$sb.AppendLine('This step has no required ``With.*`` keys at step schema level.') + [void]$sb.AppendLine('Inputs may still be provider-specific; refer to the step description and examples for usage details.') [void]$sb.AppendLine() } else {