Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/reference/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<ProviderAlias>] 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._

---
50 changes: 50 additions & 0 deletions examples/workflows/joiner-with-entraid-sync.psd1
Original file line number Diff line number Diff line change
@@ -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'
}
}
)
}
Original file line number Diff line number Diff line change
@@ -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'
}
}
}
Original file line number Diff line number Diff line change
@@ -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'
)
Original file line number Diff line number Diff line change
@@ -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)
#>
Comment thread
blindzero marked this conversation as resolved.
[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
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function Invoke-IdleProviderMethod {
[string] $MethodName,

[Parameter(Mandatory)]
[ValidateNotNull()]
[AllowEmptyCollection()]
[object[]] $MethodArguments
)

Expand Down
29 changes: 18 additions & 11 deletions src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
6 changes: 6 additions & 0 deletions src/IdLE.Steps.Common/Public/Get-IdleStepMetadataCatalog.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading