diff --git a/docs/reference/steps.md b/docs/reference/steps.md index 719900a8..5e73415a 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -297,3 +297,36 @@ Authentication: | TargetContainer | Yes | --- + +## TriggerDirectorySync + +- **Step Name**: `TriggerDirectorySync` +- **Implementation**: `Invoke-IdleStepTriggerDirectorySync` +- **Idempotent**: `Unknown` +- **Contracts**: `Unknown` +- **Events**: Unknown + +**Synopsis** + +Triggers an Entra Connect directory sync cycle and optionally waits for completion. + +**Description** + +This is a provider-agnostic step. The host must supply a provider instance via +Context.Providers[] that implements: +- StartSyncCycle(PolicyType, AuthSession) +- GetSyncCycleState(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.AuthSessionOptions (optional, hashtable): forwarded to broker for session selection +- ScriptBlocks in AuthSessionOptions are rejected (security boundary) + +**Inputs (With.\*)** + +_Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ + +--- diff --git a/examples/workflows/joiner-with-entraid-sync.psd1 b/examples/workflows/joiner-with-entraid-sync.psd1 new file mode 100644 index 00000000..b54c0337 --- /dev/null +++ b/examples/workflows/joiner-with-entraid-sync.psd1 @@ -0,0 +1,50 @@ +@{ + Name = 'Joiner - Trigger Entra Connect Sync' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Create AD account' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + IdentityKey = '{{ Request.Username }}' + Attributes = @{ + GivenName = '{{ Request.GivenName }}' + Surname = '{{ Request.Surname }}' + Department = '{{ Request.Department }}' + } + AuthSessionName = 'SourceAD' + Provider = 'Identity' + } + } + @{ + Name = 'Trigger Entra Connect Delta Sync' + Type = 'IdLE.Step.TriggerDirectorySync' + With = @{ + AuthSessionName = 'EntraConnect' + AuthSessionOptions = @{ + Role = 'EntraConnectAdmin' + } + PolicyType = 'Delta' + Wait = $true + TimeoutSeconds = 300 + PollIntervalSeconds = 10 + Provider = 'DirectorySync' + } + } + @{ + Name = 'Assign Entra ID group membership' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = '{{ Request.Username }}' + Entitlement = @{ + Kind = 'Group' + Id = 'EntraID-Users-Group' + DisplayName = 'Entra ID Users' + } + State = 'Present' + AuthSessionName = 'EntraID' + Provider = 'Cloud' + } + } + ) +} diff --git a/src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psd1 b/src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psd1 new file mode 100644 index 00000000..1a72fe47 --- /dev/null +++ b/src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psd1 @@ -0,0 +1,22 @@ +@{ + RootModule = 'IdLE.Provider.DirectorySync.EntraConnect.psm1' + ModuleVersion = '0.8.0' + GUID = 'a1b2c3d4-5e6f-7890-abcd-ef1234567890' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Entra Connect directory sync provider for IdLE (remote execution).' + PowerShellVersion = '7.0' + + FunctionsToExport = @( + 'New-IdleEntraConnectDirectorySyncProvider' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Provider', 'DirectorySync', 'EntraConnect') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' + } + } +} diff --git a/src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psm1 b/src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psm1 new file mode 100644 index 00000000..6bea7fbb --- /dev/null +++ b/src/IdLE.Provider.DirectorySync.EntraConnect/IdLE.Provider.DirectorySync.EntraConnect.psm1 @@ -0,0 +1,17 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize the list first to avoid enumeration issues if the session/module state changes during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } +} + +Export-ModuleMember -Function @( + 'New-IdleEntraConnectDirectorySyncProvider' +) diff --git a/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 b/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 new file mode 100644 index 00000000..e903de38 --- /dev/null +++ b/src/IdLE.Provider.DirectorySync.EntraConnect/Public/New-IdleEntraConnectDirectorySyncProvider.ps1 @@ -0,0 +1,201 @@ +function New-IdleEntraConnectDirectorySyncProvider { + <# + .SYNOPSIS + Creates an Entra Connect directory sync provider for IdLE. + + .DESCRIPTION + 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. + + 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) + + .EXAMPLE + $provider = New-IdleEntraConnectDirectorySyncProvider + $provider.GetCapabilities() + # 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 } + } + } + $provider = New-IdleEntraConnectDirectorySyncProvider + $result = $provider.StartSyncCycle('Delta', $mockAuthSession) + #> + [CmdletBinding()] + param() + + $provider = [pscustomobject]@{ + PSTypeName = 'IdLE.Provider.EntraConnectDirectorySync' + Name = 'EntraConnectDirectorySyncProvider' + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + <# + .SYNOPSIS + Advertises the capabilities provided by this provider instance. + + .DESCRIPTION + Capabilities are stable string identifiers used by IdLE to validate that + a workflow plan can be executed with the available providers. + #> + + return @( + 'IdLE.DirectorySync.Trigger' + 'IdLE.DirectorySync.Status' + ) + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name StartSyncCycle -Value { + <# + .SYNOPSIS + Triggers an Entra Connect sync cycle. + + .DESCRIPTION + Triggers a sync cycle via Start-ADSyncSyncCycle on the remote Entra Connect server. + + .PARAMETER PolicyType + The sync policy type: 'Delta' or 'Initial'. + + .PARAMETER AuthSession + Remote execution handle provided by the host's AuthSessionBroker. + Must implement InvokeCommand(CommandName, Parameters). + + .OUTPUTS + PSCustomObject with properties: + - Started (bool): indicates whether the sync cycle was triggered + - Message (string, optional): additional information + #> + param( + [Parameter(Mandatory)] + [ValidateSet('Delta', 'Initial', IgnoreCase = $true)] + [string] $PolicyType, + + [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." + } + + try { + # Execute Start-ADSyncSyncCycle remotely + # The remote session should already have ADSync module available or will import it + $result = $AuthSession.InvokeCommand('Start-ADSyncSyncCycle', @{ + PolicyType = $PolicyType + }) + + # Start-ADSyncSyncCycle returns a result object or throws on error + # Success case: return Started = true + return [pscustomobject]@{ + Started = $true + Message = "Sync cycle triggered with PolicyType: $PolicyType" + } + } + catch { + # Check for common privilege/elevation errors + $errorMessage = $_.Exception.Message + + if ($errorMessage -match 'access.*denied|permission|privilege|elevation|administrator|unauthorized') { + throw "Failed to start sync cycle. Missing privileges or elevation. " + + "The AuthSession must provide an elevated execution context. Original error: $errorMessage" + } + + # Re-throw other errors + throw "Failed to start sync cycle: $errorMessage" + } + } -Force + + $provider | Add-Member -MemberType ScriptMethod -Name GetSyncCycleState -Value { + <# + .SYNOPSIS + Retrieves the current state of Entra Connect sync cycles. + + .DESCRIPTION + Queries the sync scheduler state via Get-ADSyncScheduler to determine if a + sync cycle is currently in progress. + + .PARAMETER AuthSession + Remote execution handle provided by the host's AuthSessionBroker. + Must implement InvokeCommand(CommandName, Parameters). + + .OUTPUTS + PSCustomObject with properties: + - InProgress (bool): indicates whether a sync cycle is currently running + - State (string): 'InProgress', 'Idle', or 'Unknown' + - Details (hashtable, optional): additional state information + #> + param( + [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." + } + + try { + # Execute Get-ADSyncScheduler remotely + $scheduler = $AuthSession.InvokeCommand('Get-ADSyncScheduler', @{}) + + # Determine if sync is in progress + # Get-ADSyncScheduler returns an object with SyncCycleInProgress property + $inProgress = $false + $state = 'Unknown' + $details = @{} + + if ($null -ne $scheduler) { + # Extract relevant properties + if ($scheduler.PSObject.Properties.Name -contains 'SyncCycleInProgress') { + $inProgress = [bool]$scheduler.SyncCycleInProgress + $state = if ($inProgress) { 'InProgress' } else { 'Idle' } + } + + # Capture additional details for diagnostics + if ($scheduler.PSObject.Properties.Name -contains 'AllowedSyncCycleInterval') { + $details['AllowedSyncCycleInterval'] = $scheduler.AllowedSyncCycleInterval + } + if ($scheduler.PSObject.Properties.Name -contains 'NextSyncCyclePolicyType') { + $details['NextSyncCyclePolicyType'] = $scheduler.NextSyncCyclePolicyType + } + } + + return [pscustomobject]@{ + InProgress = $inProgress + State = $state + Details = $details + } + } + catch { + $errorMessage = $_.Exception.Message + + if ($errorMessage -match 'access.*denied|permission|privilege|elevation|administrator|unauthorized') { + throw "Failed to get sync cycle state. Missing privileges or elevation. " + + "The AuthSession must provide an elevated execution context. Original error: $errorMessage" + } + + throw "Failed to get sync cycle state: $errorMessage" + } + } -Force + + return $provider +} diff --git a/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 index 84f98306..a8d28431 100644 --- a/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 +++ b/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 @@ -21,7 +21,7 @@ function Invoke-IdleProviderMethod { [string] $MethodName, [Parameter(Mandatory)] - [ValidateNotNull()] + [AllowEmptyCollection()] [object[]] $MethodArguments ) diff --git a/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 b/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 index 8d656539..870217a6 100644 --- a/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 +++ b/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 @@ -17,19 +17,26 @@ function Test-IdleProviderMethodParameter { # For ScriptMethod, inspect the AST if ($ProviderMethod.MemberType -eq 'ScriptMethod') { $scriptBlock = $ProviderMethod.Script - if ($null -ne $scriptBlock -and $null -ne $scriptBlock.Ast -and $null -ne $scriptBlock.Ast.ParamBlock) { - $params = $scriptBlock.Ast.ParamBlock.Parameters - if ($null -ne $params) { - foreach ($param in $params) { - if ($null -ne $param.Name -and $null -ne $param.Name.VariablePath) { - $paramName = $param.Name.VariablePath.UserPath - if ($paramName -eq $ParameterName) { - return $true - } - } - } + + # Early exit if required objects are missing + if ($null -eq $scriptBlock) { return $false } + if ($null -eq $scriptBlock.Ast) { return $false } + if ($null -eq $scriptBlock.Ast.ParamBlock) { return $false } + + $params = $scriptBlock.Ast.ParamBlock.Parameters + if ($null -eq $params) { return $false } + + # Check each parameter for a match + foreach ($param in $params) { + if ($null -eq $param.Name) { continue } + if ($null -eq $param.Name.VariablePath) { continue } + + $paramName = $param.Name.VariablePath.UserPath + if ($paramName -eq $ParameterName) { + return $true } } + return $false } diff --git a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 index 2f266676..c449c769 100644 --- a/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 +++ b/src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1 @@ -63,5 +63,11 @@ function Get-IdleStepMetadataCatalog { RequiredCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant', 'IdLE.Entitlement.Revoke') } + # IdLE.Step.TriggerDirectorySync - requires trigger and status capabilities + # Note: Even when With.Wait = $false, we advertise Status capability to keep planning deterministic + $catalog['IdLE.Step.TriggerDirectorySync'] = @{ + RequiredCapabilities = @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } + return $catalog } diff --git a/src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psd1 b/src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psd1 new file mode 100644 index 00000000..c372e008 --- /dev/null +++ b/src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psd1 @@ -0,0 +1,26 @@ +@{ + RootModule = 'IdLE.Steps.DirectorySync.EntraConnect.psm1' + ModuleVersion = '0.8.0' + GUID = 'b2c3d4e5-6f78-9012-bcde-f12345678901' + Author = 'Matthias Fleschuetz' + Copyright = '(c) Matthias Fleschuetz. All rights reserved.' + Description = 'Entra Connect directory sync steps for IdLE.' + PowerShellVersion = '7.0' + + RequiredModules = @( + '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1' + ) + + FunctionsToExport = @( + 'Invoke-IdleStepTriggerDirectorySync' + ) + + PrivateData = @{ + PSData = @{ + Tags = @('IdentityLifecycleEngine', 'IdLE', 'Steps', 'DirectorySync', 'EntraConnect') + LicenseUri = 'https://www.apache.org/licenses/LICENSE-2.0' + ProjectUri = 'https://github.com/blindzero/IdentityLifecycleEngine' + ContactEmail = '13959569+blindzero@users.noreply.github.com' + } + } +} diff --git a/src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psm1 b/src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psm1 new file mode 100644 index 00000000..cb677b8a --- /dev/null +++ b/src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psm1 @@ -0,0 +1,29 @@ +#requires -Version 7.0 +Set-StrictMode -Version Latest + +# Import private helper functions from IdLE.Steps.Common +$commonModule = Get-Module -Name 'IdLE.Steps.Common' +if ($null -ne $commonModule) { + $commonPrivatePath = Join-Path -Path $commonModule.ModuleBase -ChildPath 'Private' + if (Test-Path -Path $commonPrivatePath) { + $privateScripts = @(Get-ChildItem -Path $commonPrivatePath -Filter '*.ps1' -File | Sort-Object -Property FullName) + foreach ($script in $privateScripts) { + . $script.FullName + } + } +} + +$PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' +if (Test-Path -Path $PublicPath) { + + # Materialize first to avoid enumeration issues during import. + $publicScripts = @(Get-ChildItem -Path $PublicPath -Filter '*.ps1' -File | Sort-Object -Property FullName) + + foreach ($script in $publicScripts) { + . $script.FullName + } +} + +Export-ModuleMember -Function @( + 'Invoke-IdleStepTriggerDirectorySync' +) diff --git a/src/IdLE.Steps.DirectorySync.EntraConnect/Public/Invoke-IdleStepTriggerDirectorySync.ps1 b/src/IdLE.Steps.DirectorySync.EntraConnect/Public/Invoke-IdleStepTriggerDirectorySync.ps1 new file mode 100644 index 00000000..e194aac3 --- /dev/null +++ b/src/IdLE.Steps.DirectorySync.EntraConnect/Public/Invoke-IdleStepTriggerDirectorySync.ps1 @@ -0,0 +1,213 @@ +function Invoke-IdleStepTriggerDirectorySync { + <# + .SYNOPSIS + Triggers an Entra Connect directory sync cycle and optionally waits for completion. + + .DESCRIPTION + This is a provider-agnostic step. The host must supply a provider instance via + Context.Providers[] that implements: + - StartSyncCycle(PolicyType, AuthSession) + - GetSyncCycleState(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.AuthSessionOptions (optional, hashtable): forwarded to broker for session selection + - ScriptBlocks in AuthSessionOptions are rejected (security boundary) + + .PARAMETER Context + Execution context created by IdLE.Core. + + .PARAMETER Step + Normalized step object from the plan. Must contain a 'With' hashtable with keys: + - AuthSessionName (required, string): auth session name for broker + - PolicyType (required, string): 'Delta' or 'Initial' (case-insensitive) + - 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 + - PollIntervalSeconds (optional, int): poll interval, defaults to 10 + - AuthSessionOptions (optional, hashtable): forwarded to broker + + .OUTPUTS + PSCustomObject (PSTypeName: IdLE.StepResult) + + .EXAMPLE + $step = @{ + Name = 'Trigger Entra Connect sync' + Type = 'IdLE.Step.TriggerDirectorySync' + With = @{ + AuthSessionName = 'EntraConnect' + PolicyType = 'Delta' + Wait = $true + } + } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + $with = $Step.With + if ($null -eq $with -or -not ($with -is [hashtable])) { + throw "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" + } + + # Optional inputs with defaults + $providerAlias = if ($with.ContainsKey('Provider')) { [string]$with.Provider } else { 'DirectorySync' } + $wait = if ($with.ContainsKey('Wait')) { [bool]$with.Wait } else { $false } + $timeoutSeconds = if ($with.ContainsKey('TimeoutSeconds')) { [int]$with.TimeoutSeconds } else { 600 } + $pollIntervalSeconds = if ($with.ContainsKey('PollIntervalSeconds')) { [int]$with.PollIntervalSeconds } else { 10 } + + # Validate timeout and poll interval + if ($timeoutSeconds -le 0) { + throw "TriggerDirectorySync: With.TimeoutSeconds must be greater than 0. Got: $timeoutSeconds" + } + if ($pollIntervalSeconds -le 0) { + throw "TriggerDirectorySync: With.PollIntervalSeconds must be greater than 0. Got: $pollIntervalSeconds" + } + + # Validate provider exists + if (-not ($Context.PSObject.Properties.Name -contains 'Providers')) { + throw "Context does not contain a Providers hashtable." + } + if ($null -eq $Context.Providers -or -not ($Context.Providers -is [hashtable])) { + throw "Context.Providers must be a hashtable." + } + if (-not $Context.Providers.ContainsKey($providerAlias)) { + throw "Provider '$providerAlias' was not supplied by the host." + } + + $stepName = if ($Step.PSObject.Properties.Name -contains 'Name') { [string]$Step.Name } else { 'TriggerDirectorySync' } + + try { + # Trigger sync cycle + $Context.EventSink.WriteEvent('DirectorySyncTriggered', "Triggering $policyType sync cycle", $stepName, @{ + PolicyType = $policyType + }) + + $startResult = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'StartSyncCycle' ` + -MethodArguments @($policyType) + + $changed = $false + if ($null -ne $startResult -and ($startResult.PSObject.Properties.Name -contains 'Started')) { + $changed = [bool]$startResult.Started + } + + # If wait is requested, poll until complete or timeout + if ($wait) { + $Context.EventSink.WriteEvent('DirectorySyncWaiting', "Waiting for sync cycle to complete (timeout: ${timeoutSeconds}s)", $stepName, @{ + TimeoutSeconds = $timeoutSeconds + PollIntervalSeconds = $pollIntervalSeconds + }) + + $startTime = [datetime]::UtcNow + $attempt = 0 + + while ($true) { + $attempt++ + $elapsed = ([datetime]::UtcNow - $startTime).TotalSeconds + + if ($elapsed -ge $timeoutSeconds) { + # Timeout reached - fail + $stateResult = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'GetSyncCycleState' ` + -MethodArguments @() + + $lastState = if ($null -ne $stateResult) { $stateResult.State } else { 'Unknown' } + + $Context.EventSink.WriteEvent('DirectorySyncFailed', "Sync cycle wait timeout after ${timeoutSeconds}s", $stepName, @{ + TimeoutSeconds = $timeoutSeconds + ElapsedSeconds = [int]$elapsed + LastKnownState = $lastState + }) + + throw "TriggerDirectorySync: Timeout waiting for sync cycle to complete after ${timeoutSeconds}s. Last known state: $lastState" + } + + # Poll state + $stateResult = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'GetSyncCycleState' ` + -MethodArguments @() + + $inProgress = $true + if ($null -ne $stateResult -and ($stateResult.PSObject.Properties.Name -contains 'InProgress')) { + $inProgress = [bool]$stateResult.InProgress + } + + $currentState = if ($null -ne $stateResult) { $stateResult.State } else { 'Unknown' } + + $Context.EventSink.WriteEvent('DirectorySyncPoll', "Poll attempt $attempt - State: $currentState", $stepName, @{ + Attempt = $attempt + State = $currentState + InProgress = $inProgress + ElapsedSeconds = [int]$elapsed + }) + + if (-not $inProgress) { + # Sync cycle completed + $Context.EventSink.WriteEvent('DirectorySyncCompleted', "Sync cycle completed", $stepName, @{ + Attempts = $attempt + ElapsedSeconds = [int]$elapsed + }) + break + } + + # Wait before next poll + Start-Sleep -Seconds $pollIntervalSeconds + } + } + else { + # Not waiting - sync triggered successfully + $Context.EventSink.WriteEvent('DirectorySyncCompleted', "Sync cycle triggered (not waiting)", $stepName, @{ + PolicyType = $policyType + }) + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = [string]$Step.Type + Status = 'Completed' + Changed = $changed + Error = $null + } + } + catch { + $Context.EventSink.WriteEvent('DirectorySyncFailed', "Failed to trigger or wait for sync cycle: $_", $stepName, @{ + Error = $_.Exception.Message + }) + throw + } +} diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 index 36c5bb08..5f4f2abc 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -10,6 +10,8 @@ NestedModules = @( '..\IdLE.Core\IdLE.Core.psd1', '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1', + '..\IdLE.Steps.DirectorySync.EntraConnect\IdLE.Steps.DirectorySync.EntraConnect.psd1', + '..\IdLE.Provider.DirectorySync.EntraConnect\IdLE.Provider.DirectorySync.EntraConnect.psd1', '..\IdLE.Provider.AD\IdLE.Provider.AD.psd1' ) diff --git a/tests/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 b/tests/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 new file mode 100644 index 00000000..0aeffc47 --- /dev/null +++ b/tests/Invoke-IdleStepTriggerDirectorySync.Tests.ps1 @@ -0,0 +1,323 @@ +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Invoke-IdleStepTriggerDirectorySync (DirectorySync step)' { + BeforeEach { + # Mock directory sync provider + $script:MockProvider = [pscustomobject]@{ + PSTypeName = 'Mock.DirectorySyncProvider' + Name = 'MockDirectorySyncProvider' + PollCount = 0 + } + + $script:MockProvider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('IdLE.DirectorySync.Trigger', 'IdLE.DirectorySync.Status') + } -Force + + $script:MockProvider | Add-Member -MemberType ScriptMethod -Name StartSyncCycle -Value { + param( + [Parameter(Mandatory)] + [string] $PolicyType, + + [Parameter(Mandatory)] + [object] $AuthSession + ) + + return [pscustomobject]@{ + Started = $true + Message = "Sync cycle triggered with PolicyType: $PolicyType" + } + } -Force + + $script:MockProvider | Add-Member -MemberType ScriptMethod -Name GetSyncCycleState -Value { + param( + [Parameter(Mandatory)] + [object] $AuthSession + ) + + # Increment poll count and determine state + $this.PollCount++ + + $inProgress = $this.PollCount -le 1 + $state = if ($inProgress) { 'InProgress' } else { 'Idle' } + + return [pscustomobject]@{ + InProgress = $inProgress + State = $state + Details = @{} + } + } -Force + + # Mock auth session + $script:MockAuthSession = [pscustomobject]@{ Name = 'MockAuthSession' } + + # Mock context + $script:Context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $null + Providers = @{ DirectorySync = $script:MockProvider } + } + + # Add EventSink as a ScriptMethod + $script:Context | Add-Member -NotePropertyName 'EventSink' -NotePropertyValue ([pscustomobject]@{}) + $script:Context.EventSink | Add-Member -MemberType ScriptMethod -Name WriteEvent -Value { + param($Type, $Message, $StepName, $Data) + # No-op for most tests + } -Force + + $script:Context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter()] + [AllowNull()] + [hashtable] $Options + ) + + return $script:MockAuthSession + } -Force + + $script:StepTemplate = [pscustomobject]@{ + Name = 'Trigger sync' + Type = 'IdLE.Step.TriggerDirectorySync' + With = @{ + AuthSessionName = 'EntraConnect' + PolicyType = 'Delta' + Provider = 'DirectorySync' + } + } + } + + Context 'Input validation' { + It 'throws when With is missing' { + $step = [pscustomobject]@{ + Name = 'Trigger sync' + Type = 'IdLE.Step.TriggerDirectorySync' + } + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw + } + + It 'throws when With.AuthSessionName is missing' { + $step = $script:StepTemplate + $step.With.Remove('AuthSessionName') + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*AuthSessionName*' + } + + It 'throws when With.PolicyType is missing' { + $step = $script:StepTemplate + $step.With.Remove('PolicyType') + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PolicyType*' + } + + It 'throws when With.PolicyType is invalid' { + $step = $script:StepTemplate + $step.With.PolicyType = 'Invalid' + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PolicyType*' + } + + It 'accepts Delta as PolicyType' { + $step = $script:StepTemplate + $step.With.PolicyType = 'Delta' + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'accepts Initial as PolicyType' { + $step = $script:StepTemplate + $step.With.PolicyType = 'Initial' + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'uses default provider alias when not specified' { + $step = $script:StepTemplate + $step.With.Remove('Provider') + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + + It 'throws when TimeoutSeconds is invalid' { + $step = $script:StepTemplate + $step.With.TimeoutSeconds = -1 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*TimeoutSeconds*' + } + + It 'throws when PollIntervalSeconds is invalid' { + $step = $script:StepTemplate + $step.With.PollIntervalSeconds = 0 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*PollIntervalSeconds*' + } + } + + Context 'Trigger without wait' { + It 'triggers sync cycle and completes immediately' { + $step = $script:StepTemplate + $step.With.Wait = $false + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + $result.Error | Should -BeNullOrEmpty + } + + It 'defaults to not waiting when Wait is not specified' { + $step = $script:StepTemplate + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + } + } + + Context 'Trigger with wait' { + It 'triggers and waits for completion' { + $step = $script:StepTemplate + $step.With.Wait = $true + $step.With.PollIntervalSeconds = 1 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $result.Status | Should -Be 'Completed' + $result.Changed | Should -BeTrue + } + + 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) + return [pscustomobject]@{ + InProgress = $true + State = 'InProgress' + Details = @{} + } + } -Force + + $step = $script:StepTemplate + $step.With.Wait = $true + $step.With.TimeoutSeconds = 2 + $step.With.PollIntervalSeconds = 1 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $step } | Should -Throw -ErrorId * -ExpectedMessage '*Timeout*' + } + + It 'polls provider state multiple times' { + # 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) + $this.PollCount++ + $inProgress = $this.PollCount -le 2 + return [pscustomobject]@{ + InProgress = $inProgress + State = if ($inProgress) { 'InProgress' } else { 'Idle' } + Details = @{} + } + } -Force + + $step = $script:StepTemplate + $step.With.Wait = $true + $step.With.PollIntervalSeconds = 1 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $result = & $handler -Context $script:Context -Step $step + + $script:MockProvider.PollCount | Should -BeGreaterThan 1 + } + } + + Context 'Provider interaction' { + It 'throws when provider is missing' { + $script:Context.Providers.Clear() + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * -ExpectedMessage '*Provider*' + } + + It 'throws when provider does not implement StartSyncCycle' { + $badProvider = [pscustomobject]@{ Name = 'BadProvider' } + $script:Context.Providers['DirectorySync'] = $badProvider + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + { & $handler -Context $script:Context -Step $script:StepTemplate } | Should -Throw -ErrorId * -ExpectedMessage '*StartSyncCycle*' + } + } + + Context 'Event emission' { + It 'emits DirectorySyncTriggered event' { + $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 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $null = & $handler -Context $script:Context -Step $script:StepTemplate + + $capturedEvents.Type | Should -Contain 'DirectorySyncTriggered' + } + + It 'emits DirectorySyncCompleted event' { + $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 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $null = & $handler -Context $script:Context -Step $script:StepTemplate + + $capturedEvents.Type | Should -Contain 'DirectorySyncCompleted' + } + + It 'emits DirectorySyncWaiting event when waiting' { + $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.Wait = $true + $step.With.PollIntervalSeconds = 1 + + $handler = 'IdLE.Steps.DirectorySync.EntraConnect\Invoke-IdleStepTriggerDirectorySync' + $null = & $handler -Context $script:Context -Step $step + + $capturedEvents.Type | Should -Contain 'DirectorySyncWaiting' + } + } +} diff --git a/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 b/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 new file mode 100644 index 00000000..42a24090 --- /dev/null +++ b/tests/Providers/EntraConnectDirectorySyncProvider.Tests.ps1 @@ -0,0 +1,126 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule + + # $PSScriptRoot = ...\tests\Providers + # repo root = parent of ...\tests + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + + $capabilitiesContractPath = Join-Path -Path $repoRoot -ChildPath 'tests\ProviderContracts\ProviderCapabilities.Contract.ps1' + if (-not (Test-Path -LiteralPath $capabilitiesContractPath -PathType Leaf)) { + throw "Provider capabilities contract not found at: $capabilitiesContractPath" + } + . $capabilitiesContractPath +} + +Describe 'Entra Connect directory sync provider contracts' { + Invoke-IdleProviderCapabilitiesContractTests -ProviderFactory { New-IdleEntraConnectDirectorySyncProvider } + + Context 'Directory sync provider methods' { + BeforeAll { + $script:Provider = New-IdleEntraConnectDirectorySyncProvider + + # 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' + } + } + + throw "Unexpected command: $CommandName" + } -Force + } + + It 'Exposes StartSyncCycle method' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'StartSyncCycle' + } + + It 'StartSyncCycle accepts PolicyType and AuthSession parameters' { + $result = $script:Provider.StartSyncCycle('Delta', $script:MockAuthSession) + + $result | Should -Not -BeNullOrEmpty + $result.PSObject.Properties.Name | Should -Contain 'Started' + $result.PSObject.Properties.Name | Should -Contain 'Message' + } + + It 'StartSyncCycle validates PolicyType' { + { $script:Provider.StartSyncCycle('Invalid', $script:MockAuthSession) } | Should -Throw + } + + It 'StartSyncCycle validates AuthSession implements InvokeCommand' { + $badSession = [pscustomobject]@{ Name = 'BadSession' } + { $script:Provider.StartSyncCycle('Delta', $badSession) } | Should -Throw -ErrorId * -ExpectedMessage '*InvokeCommand*' + } + + It 'Exposes GetSyncCycleState method' { + $script:Provider.PSObject.Methods.Name | Should -Contain 'GetSyncCycleState' + } + + It 'GetSyncCycleState accepts AuthSession parameter' { + $result = $script:Provider.GetSyncCycleState($script:MockAuthSession) + + $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' + } + + It 'GetSyncCycleState returns correct InProgress value' { + $result = $script:Provider.GetSyncCycleState($script:MockAuthSession) + + $result.InProgress | Should -BeOfType [bool] + } + + It 'GetSyncCycleState validates AuthSession implements InvokeCommand' { + $badSession = [pscustomobject]@{ Name = 'BadSession' } + { $script:Provider.GetSyncCycleState($badSession) } | Should -Throw -ErrorId * -ExpectedMessage '*InvokeCommand*' + } + } + + Context 'Provider capability advertisement' { + BeforeAll { + $script:Provider = New-IdleEntraConnectDirectorySyncProvider + } + + It 'Advertises IdLE.DirectorySync.Trigger capability' { + $caps = $script:Provider.GetCapabilities() + $caps | Should -Contain 'IdLE.DirectorySync.Trigger' + } + + It 'Advertises IdLE.DirectorySync.Status capability' { + $caps = $script:Provider.GetCapabilities() + $caps | Should -Contain 'IdLE.DirectorySync.Status' + } + + It 'Advertises exactly 2 capabilities' { + $caps = $script:Provider.GetCapabilities() + $caps.Count | Should -Be 2 + } + } +} diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index df12357d..8c3ff658 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -26,8 +26,14 @@ function Import-IdleTestModule { $stepsCommonManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Steps.Common/IdLE.Steps.Common.psd1') Import-Module -Name $stepsCommonManifestPath -Force -ErrorAction Stop + $stepsDirectorySyncManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Steps.DirectorySync.EntraConnect/IdLE.Steps.DirectorySync.EntraConnect.psd1') + Import-Module -Name $stepsDirectorySyncManifestPath -Force -ErrorAction Stop + $mockProviderManifestPath = Resolve-Path -Path (Join-Path (Get-RepoRootPath) 'src/IdLE.Provider.Mock/IdLE.Provider.Mock.psd1') Import-Module -Name $mockProviderManifestPath -Force -ErrorAction Stop + + $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 Get-ModuleManifestPaths { diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index f09e57dd..898a73b3 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -13,7 +13,7 @@ param( # Restrict which step modules are scanned. [Parameter()] [ValidateNotNullOrEmpty()] - [string[]] $StepModules = @('IdLE.Steps.Common'), + [string[]] $StepModules = @('IdLE.Steps.Common', 'IdLE.Steps.DirectorySync.EntraConnect'), # Optional: Step function names to exclude (exact command names). [Parameter()]