diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cd43ca9..8f2a7a7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,7 +38,11 @@ If anything in this file conflicts with those, the more specific document wins. ## Documentation rules - Do not edit generated references under `docs/reference/` by hand. - - Regenerate via the repository tools as documented in `CONTRIBUTING.md`. + - **Always regenerate** after changing public cmdlets or step implementations: + - `./tools/Generate-IdleCmdletReference.ps1` - after cmdlet/help changes + - `./tools/Generate-IdleStepReference.ps1` - after step changes + - See `CONTRIBUTING.md` for complete instructions. + - CI will fail if generated docs are out of date. ## Git / PR conventions diff --git a/docs/reference/provider-ad.md b/docs/reference/provider-ad.md index 1190ba8..ce2dbbf 100644 --- a/docs/reference/provider-ad.md +++ b/docs/reference/provider-ad.md @@ -97,13 +97,83 @@ $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Provider } ``` -### With Explicit Credentials +### AuthSessionBroker-based Authentication + +Use an AuthSessionBroker to manage authentication centrally and enable multi-role scenarios. + +**Simple approach with New-IdleAuthSessionBroker:** ```powershell -$credential = Get-Credential -$provider = New-IdleADIdentityProvider -Credential $credential +# Assuming you have credentials available (e.g., from a secure vault or credential manager) +$tier0Credential = Get-Credential -Message "Enter Tier0 admin credentials" +$adminCredential = Get-Credential -Message "Enter regular admin credentials" + +# Create provider +$provider = New-IdleADIdentityProvider + +# Create broker with role-based credential mapping +$broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Role = 'Tier0' } = $tier0Credential + @{ Role = 'Admin' } = $adminCredential +} -DefaultCredential $adminCredential + +# Use provider with broker +$plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + AuthSessionBroker = $broker +} +``` + +**Custom broker for advanced scenarios:** + +For advanced scenarios (vault integration, MFA, dynamic credential retrieval), implement a custom broker: + +```powershell +$broker = [pscustomobject]@{} +$broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + # Custom logic: retrieve from vault, prompt for MFA, etc. + if ($Options.Role -eq 'Tier0') { + return Get-SecretFromVault -Name 'AD-Tier0' + } + return Get-SecretFromVault -Name 'AD-Admin' +} +``` + +In workflow definitions, steps specify which auth context to use via `AuthSessionOptions`: + +```powershell +@{ + Type = 'IdLE.Step.EnsureAttribute' + Name = 'SetPrivilegedAttribute' + With = @{ + IdentityKey = 'user@domain.com' + Name = 'AdminCount' + Value = 1 + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = @{ Role = 'Tier0' } # Broker returns Tier0 credential + } +} + +@{ + Type = 'IdLE.Step.EnsureAttribute' + Name = 'SetDepartment' + With = @{ + IdentityKey = 'user@domain.com' + Name = 'Department' + Value = 'IT' + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = @{ Role = 'Admin' } # Broker returns Admin credential + } +} ``` +**Key points:** +- The `Role` key (or any other key) is **defined by you** - it's not a built-in keyword +- Your broker implementation decides how to interpret `AuthSessionOptions` +- The broker can use any logic you want: hashtable lookups, vault APIs, interactive prompts, etc. +- `AuthSessionOptions` must be data-only (no ScriptBlocks) for security + ### With Delete Capability (Opt-in) By default, the Delete capability is **not** advertised for safety. Enable it explicitly: @@ -114,13 +184,54 @@ $provider = New-IdleADIdentityProvider -AllowDelete ### Multi-Provider Scenarios +For scenarios with multiple AD forests or domains, use provider aliases with the AuthSessionBroker: + ```powershell -$sourceAD = New-IdleADIdentityProvider -Credential $sourceCred -$targetAD = New-IdleADIdentityProvider -Credential $targetCred -AllowDelete +# Assuming you have credentials for each domain +$sourceCred = Get-Credential -Message "Enter Source AD admin credentials" +$targetCred = Get-Credential -Message "Enter Target AD admin credentials" + +# Create providers for different AD environments +$sourceAD = New-IdleADIdentityProvider +$targetAD = New-IdleADIdentityProvider -AllowDelete + +# Use New-IdleAuthSessionBroker for domain-based credential routing +$broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Domain = 'Source' } = $sourceCred + @{ Domain = 'Target' } = $targetCred +} $plan = New-IdlePlan -WorkflowPath './migration.psd1' -Request $request -Providers @{ SourceAD = $sourceAD TargetAD = $targetAD + AuthSessionBroker = $broker +} +``` + +Workflow steps specify which domain to authenticate against: + +```powershell +@{ + Type = 'IdLE.Step.GetIdentity' + Name = 'ReadSource' + With = @{ + IdentityKey = 'user@source.com' + Provider = 'SourceAD' + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = @{ Domain = 'Source' } + } +} + +@{ + Type = 'IdLE.Step.CreateIdentity' + Name = 'CreateTarget' + With = @{ + IdentityKey = 'user@target.com' + Attributes = @{ ... } + Provider = 'TargetAD' + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = @{ Domain = 'Target' } + } } ``` diff --git a/docs/reference/steps.md b/docs/reference/steps.md index ae8ac50..719900a 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -28,6 +28,14 @@ and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity already exists, the provider should return Changed = $false without creating a duplicate. +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** | Key | Required | @@ -62,6 +70,14 @@ IMPORTANT: This step requires the provider to advertise the IdLE.Identity.Delete capability, which is typically opt-in for safety. The provider must be configured to allow deletion (e.g., AllowDelete = $true for AD provider). +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** _Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ @@ -89,6 +105,14 @@ and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already disabled, the provider should return Changed = $false. +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** _Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ @@ -140,6 +164,14 @@ and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already enabled, the provider should return Changed = $false. +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** _Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ @@ -167,6 +199,14 @@ contains a boolean property 'Changed'. The step is idempotent by design: it converges state to the desired value. +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** | Key | Required | @@ -202,6 +242,14 @@ via `Context.Providers[]` that implements: The step is idempotent and only calls Grant/Revoke when the assignment needs to change. +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider methods + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** | Key | Required | @@ -233,6 +281,14 @@ and returns an object with properties 'IdentityKey' and 'Changed'. The step is idempotent by design: if the identity is already in the target container, the provider should return Changed = $false. +Authentication: +- If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. +- With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). +- ScriptBlocks in AuthSessionOptions are rejected (security boundary). + **Inputs (With.\*)** | Key | Required | diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 466cf73..969fd60 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -23,5 +23,6 @@ Export-ModuleMember -Function @( 'Test-IdleWorkflowDefinitionObject', 'New-IdlePlanObject', 'Invoke-IdlePlanObject', - 'Export-IdlePlanObject' + 'Export-IdlePlanObject', + 'New-IdleAuthSessionBroker' ) -Alias @() diff --git a/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 new file mode 100644 index 0000000..f22c76a --- /dev/null +++ b/src/IdLE.Core/Public/New-IdleAuthSessionBroker.ps1 @@ -0,0 +1,116 @@ +function New-IdleAuthSessionBroker { + <# + .SYNOPSIS + Creates a simple AuthSessionBroker for use with IdLE providers. + + .DESCRIPTION + Creates an AuthSessionBroker that routes authentication based on user-defined options. + The broker is used by steps to acquire credentials at runtime without embedding + secrets in workflows or provider construction. + + This is a convenience function for common scenarios. For advanced scenarios + (vault integration, MFA, etc.), implement a custom broker object with an + AcquireAuthSession method. + + .PARAMETER SessionMap + A hashtable that maps session configurations to credentials. Each key is a hashtable + representing the AuthSessionOptions pattern, and each value is the PSCredential to return. + + Common patterns: + - @{ Role = 'Tier0' } -> $tier0Credential + - @{ Role = 'Admin' } -> $adminCredential + - @{ Domain = 'SourceAD' } -> $sourceCred + - @{ Environment = 'Production' } -> $prodCred + + .PARAMETER DefaultCredential + Optional default credential to return when no session options are provided or + when the options don't match any entry in SessionMap. + + .EXAMPLE + # Simple role-based broker + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Role = 'Tier0' } = $tier0Credential + @{ Role = 'Admin' } = $adminCredential + } -DefaultCredential $adminCredential + + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = New-IdleADIdentityProvider + AuthSessionBroker = $broker + } + + .EXAMPLE + # Domain-based broker for multi-forest scenarios + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Domain = 'SourceAD' } = $sourceCred + @{ Domain = 'TargetAD' } = $targetCred + } + + .OUTPUTS + PSCustomObject with AcquireAuthSession method + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $SessionMap, + + [Parameter()] + [AllowNull()] + [PSCredential] $DefaultCredential + ) + + $broker = [pscustomobject]@{ + PSTypeName = 'IdLE.AuthSessionBroker' + SessionMap = $SessionMap + DefaultCredential = $DefaultCredential + } + + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + [Parameter()] + [AllowNull()] + [hashtable] $Options + ) + + # If no options provided, return default + if ($null -eq $Options -or $Options.Count -eq 0) { + if ($null -ne $this.DefaultCredential) { + return $this.DefaultCredential + } + throw "No auth session options provided and no default credential configured." + } + + # Find matching session in map + foreach ($entry in $this.SessionMap.GetEnumerator()) { + $pattern = $entry.Key + $credential = $entry.Value + + # Check if all keys in pattern match Options + $matches = $true + foreach ($key in $pattern.Keys) { + if (-not $Options.ContainsKey($key) -or $Options[$key] -ne $pattern[$key]) { + $matches = $false + break + } + } + + if ($matches) { + return $credential + } + } + + # No match found + if ($null -ne $this.DefaultCredential) { + return $this.DefaultCredential + } + + $optionsStr = ($Options.Keys | ForEach-Object { "$_=$($Options[$_])" }) -join ', ' + throw "No matching credential found for options: $optionsStr" + } -Force + + return $broker +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 50d7b13..d3c16cc 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -15,8 +15,14 @@ function New-IdleADIdentityProvider { - UPN (UserPrincipalName) - contains @ - sAMAccountName - default fallback - .PARAMETER Credential - Optional PSCredential for AD operations. If not provided, uses integrated auth (run-as). + Authentication: + Provider methods accept an optional AuthSession parameter for runtime credential + selection via the AuthSessionBroker. This enables multi-role scenarios (e.g., + Tier0 vs. Admin) without embedding credentials in the provider or workflow. + + By default, the provider uses integrated authentication (run-as credentials). + For runtime credential selection, configure an AuthSessionBroker and use + With.AuthSessionName and With.AuthSessionOptions in step definitions. .PARAMETER AllowDelete Opt-in flag to enable the IdLE.Identity.Delete capability. @@ -28,20 +34,51 @@ function New-IdleADIdentityProvider { a fake AD adapter without requiring a real Active Directory environment. .EXAMPLE + # Use integrated authentication (run-as) + $provider = New-IdleADIdentityProvider + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + } + + .EXAMPLE + # Multi-role scenario with New-IdleAuthSessionBroker (recommended) + $tier0Credential = Get-Credential -Message "Enter Tier0 admin credentials" + $adminCredential = Get-Credential -Message "Enter regular admin credentials" + + $broker = New-IdleAuthSessionBroker -SessionMap @{ + @{ Role = 'Tier0' } = $tier0Credential + @{ Role = 'Admin' } = $adminCredential + } -DefaultCredential $adminCredential + $provider = New-IdleADIdentityProvider - $provider.GetIdentity('user@domain.com') + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + AuthSessionBroker = $broker + } + + # Workflow steps can specify different auth contexts: + # With.AuthSessionName = 'ActiveDirectory' + # With.AuthSessionOptions = @{ Role = 'Tier0' } .EXAMPLE - $cred = Get-Credential - $provider = New-IdleADIdentityProvider -Credential $cred -AllowDelete $true - $provider.DeleteIdentity('user@domain.com') + # Custom broker for advanced scenarios (vault integration, MFA) + $broker = [pscustomobject]@{} + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + if ($Options.Role -eq 'Tier0') { + return Get-SecretFromVault -Name 'AD-Tier0' + } + return Get-SecretFromVault -Name 'AD-Admin' + } + + $provider = New-IdleADIdentityProvider + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + AuthSessionBroker = $broker + } #> [CmdletBinding()] param( - [Parameter()] - [AllowNull()] - [PSCredential] $Credential, - [Parameter()] [switch] $AllowDelete, @@ -51,7 +88,7 @@ function New-IdleADIdentityProvider { ) if ($null -eq $Adapter) { - $Adapter = New-IdleADAdapter -Credential $Credential + $Adapter = New-IdleADAdapter } $convertToEntitlement = { @@ -124,14 +161,20 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $IdentityKey + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) + $adapter = $this.GetEffectiveAdapter($AuthSession) + # Try GUID format first (most deterministic) $guid = [System.Guid]::Empty if ([System.Guid]::TryParse($IdentityKey, [ref]$guid)) { try { - $user = $this.Adapter.GetUserByGuid($guid.ToString()) + $user = $adapter.GetUserByGuid($guid.ToString()) } catch [System.Management.Automation.MethodException] { Write-Verbose "GetUserByGuid failed for GUID '$IdentityKey': $_" @@ -146,7 +189,7 @@ function New-IdleADIdentityProvider { # Try UPN format (contains @) if ($IdentityKey -match '@') { - $user = $this.Adapter.GetUserByUpn($IdentityKey) + $user = $adapter.GetUserByUpn($IdentityKey) if ($null -ne $user) { return $user } @@ -154,7 +197,7 @@ function New-IdleADIdentityProvider { } # Fallback to sAMAccountName - $user = $this.Adapter.GetUserBySam($IdentityKey) + $user = $adapter.GetUserBySam($IdentityKey) if ($null -ne $user) { return $user } @@ -165,10 +208,16 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $GroupId + [string] $GroupId, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $group = $this.Adapter.GetGroupById($GroupId) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $group = $adapter.GetGroupById($GroupId) if ($null -eq $group) { throw "Group '$GroupId' not found." } @@ -183,6 +232,34 @@ function New-IdleADIdentityProvider { AllowDelete = [bool]$AllowDelete } + # Helper method to extract credential from AuthSession and create effective adapter + $getEffectiveAdapter = { + param( + [Parameter()] + [AllowNull()] + [object] $AuthSession + ) + + if ($null -eq $AuthSession) { + return $this.Adapter + } + + $credential = $null + if ($AuthSession -is [PSCredential]) { + $credential = $AuthSession + } + elseif ($AuthSession.PSObject.Properties.Name -contains 'Credential') { + $credential = $AuthSession.Credential + } + + if ($null -ne $credential) { + return New-IdleADAdapter -Credential $credential + } + + return $this.Adapter + } + + $provider | Add-Member -MemberType ScriptMethod -Name GetEffectiveAdapter -Value $getEffectiveAdapter -Force $provider | Add-Member -MemberType ScriptMethod -Name ConvertToEntitlement -Value $convertToEntitlement -Force $provider | Add-Member -MemberType ScriptMethod -Name TestEntitlementEquals -Value $testEntitlementEquals -Force $provider | Add-Member -MemberType ScriptMethod -Name ResolveIdentity -Value $resolveIdentity -Force @@ -213,10 +290,16 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $IdentityKey + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $user = $this.ResolveIdentity($IdentityKey) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $attributes = @{} if ($null -ne $user.GivenName) { $attributes['GivenName'] = $user.GivenName } @@ -241,10 +324,16 @@ function New-IdleADIdentityProvider { $provider | Add-Member -MemberType ScriptMethod -Name ListIdentities -Value { param( [Parameter()] - [hashtable] $Filter + [hashtable] $Filter, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $users = $this.Adapter.ListUsers($Filter) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $users = $adapter.ListUsers($Filter) $identityKeys = @() foreach ($user in $users) { $identityKeys += $user.ObjectGuid.ToString() @@ -260,11 +349,17 @@ function New-IdleADIdentityProvider { [Parameter(Mandatory)] [ValidateNotNull()] - [hashtable] $Attributes + [hashtable] $Attributes, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) + $adapter = $this.GetEffectiveAdapter($AuthSession) + try { - $existing = $this.ResolveIdentity($IdentityKey) + $existing = $this.ResolveIdentity($IdentityKey, $AuthSession) if ($null -ne $existing) { return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -284,7 +379,7 @@ function New-IdleADIdentityProvider { $enabled = [bool]$Attributes['Enabled'] } - $null = $this.Adapter.NewUser($IdentityKey, $Attributes, $enabled) + $null = $adapter.NewUser($IdentityKey, $Attributes, $enabled) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' @@ -298,16 +393,22 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $IdentityKey + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) if (-not $this.AllowDelete) { throw "Delete capability is not enabled. Set AllowDelete = `$true when creating the provider." } + $adapter = $this.GetEffectiveAdapter($AuthSession) + try { - $user = $this.ResolveIdentity($IdentityKey) - $this.Adapter.DeleteUser($user.DistinguishedName) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + $adapter.DeleteUser($user.DistinguishedName) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'DeleteIdentity' @@ -350,10 +451,16 @@ function New-IdleADIdentityProvider { [Parameter()] [AllowNull()] - [object] $Value + [object] $Value, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $user = $this.ResolveIdentity($IdentityKey) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $currentValue = $null if ($user.PSObject.Properties.Name -contains $Name) { @@ -362,7 +469,7 @@ function New-IdleADIdentityProvider { $changed = $false if ($currentValue -ne $Value) { - $this.Adapter.SetUser($user.DistinguishedName, $Name, $Value) + $adapter.SetUser($user.DistinguishedName, $Name, $Value) $changed = $true } @@ -384,16 +491,22 @@ function New-IdleADIdentityProvider { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $TargetContainer + [string] $TargetContainer, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $user = $this.ResolveIdentity($IdentityKey) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $currentOu = $user.DistinguishedName -replace '^CN=[^,]+,', '' $changed = $false if ($currentOu -ne $TargetContainer) { - $this.Adapter.MoveObject($user.DistinguishedName, $TargetContainer) + $adapter.MoveObject($user.DistinguishedName, $TargetContainer) $changed = $true } @@ -410,14 +523,20 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $IdentityKey + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $user = $this.ResolveIdentity($IdentityKey) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $changed = $false if ($user.Enabled -ne $false) { - $this.Adapter.DisableUser($user.DistinguishedName) + $adapter.DisableUser($user.DistinguishedName) $changed = $true } @@ -433,14 +552,20 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $IdentityKey + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $user = $this.ResolveIdentity($IdentityKey) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $changed = $false if ($user.Enabled -ne $true) { - $this.Adapter.EnableUser($user.DistinguishedName) + $adapter.EnableUser($user.DistinguishedName) $changed = $true } @@ -456,11 +581,18 @@ function New-IdleADIdentityProvider { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $IdentityKey + [string] $IdentityKey, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) - $user = $this.ResolveIdentity($IdentityKey) - $groups = $this.Adapter.GetUserGroups($user.DistinguishedName) + $adapter = $this.GetEffectiveAdapter($AuthSession) + + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + + $groups = $adapter.GetUserGroups($user.DistinguishedName) $result = @() foreach ($group in $groups) { @@ -483,19 +615,26 @@ function New-IdleADIdentityProvider { [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Entitlement + [object] $Entitlement, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) + $adapter = $this.GetEffectiveAdapter($AuthSession) + $normalized = $this.ConvertToEntitlement($Entitlement) - $user = $this.ResolveIdentity($IdentityKey) - $groupDn = $this.NormalizeGroupId($normalized.Id) - $currentGroups = $this.ListEntitlements($IdentityKey) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) + + $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } $changed = $false if (@($existing).Count -eq 0) { - $this.Adapter.AddGroupMember($groupDn, $user.DistinguishedName) + $adapter.AddGroupMember($groupDn, $user.DistinguishedName) $changed = $true } @@ -516,19 +655,26 @@ function New-IdleADIdentityProvider { [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Entitlement + [object] $Entitlement, + + [Parameter()] + [AllowNull()] + [object] $AuthSession ) + $adapter = $this.GetEffectiveAdapter($AuthSession) + $normalized = $this.ConvertToEntitlement($Entitlement) - $user = $this.ResolveIdentity($IdentityKey) - $groupDn = $this.NormalizeGroupId($normalized.Id) - $currentGroups = $this.ListEntitlements($IdentityKey) + $user = $this.ResolveIdentity($IdentityKey, $AuthSession) + $groupDn = $this.NormalizeGroupId($normalized.Id, $AuthSession) + + $currentGroups = $this.ListEntitlements($IdentityKey, $AuthSession) $existing = $currentGroups | Where-Object { $this.TestEntitlementEquals($_, $normalized) } $changed = $false if (@($existing).Count -gt 0) { - $this.Adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) + $adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) $changed = $true } diff --git a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 index 90a5012..365d5dd 100644 --- a/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 +++ b/src/IdLE.Steps.Common/IdLE.Steps.Common.psm1 @@ -1,6 +1,17 @@ #requires -Version 7.0 Set-StrictMode -Version Latest +$PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' +if (Test-Path -Path $PrivatePath) { + + # Materialize first to avoid enumeration issues during import. + $privateScripts = @(Get-ChildItem -Path $PrivatePath -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) { diff --git a/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 b/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 new file mode 100644 index 0000000..84f9830 --- /dev/null +++ b/src/IdLE.Steps.Common/Private/Invoke-IdleProviderMethod.ps1 @@ -0,0 +1,62 @@ +# Invokes a provider method with optional AuthSession support. +# Handles auth session acquisition, parameter detection, and backwards-compatible fallback. + +function Invoke-IdleProviderMethod { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] $With, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ProviderAlias, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $MethodName, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object[]] $MethodArguments + ) + + # Auth session acquisition (optional, data-only) + $authSession = $null + if ($With.ContainsKey('AuthSessionName')) { + $sessionName = [string]$With.AuthSessionName + $sessionOptions = if ($With.ContainsKey('AuthSessionOptions')) { $With.AuthSessionOptions } else { $null } + + if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { + throw "With.AuthSessionOptions must be a hashtable or null." + } + + $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) + } + + $provider = $Context.Providers[$ProviderAlias] + + # Check if provider method exists + $providerMethod = $provider.PSObject.Methods[$MethodName] + if ($null -eq $providerMethod) { + throw "Provider '$ProviderAlias' does not implement $MethodName method." + } + + # Check if method supports AuthSession parameter + $supportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $providerMethod -ParameterName 'AuthSession' + + # Call provider method with appropriate signature + if ($supportsAuthSession -and $null -ne $authSession) { + # Provider supports AuthSession and we have one - pass it + $allArgs = $MethodArguments + $authSession + return $provider.$MethodName.Invoke($allArgs) + } + else { + # Legacy signature (no AuthSession parameter) or no session acquired + return $provider.$MethodName.Invoke($MethodArguments) + } +} diff --git a/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 b/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 new file mode 100644 index 0000000..8d65653 --- /dev/null +++ b/src/IdLE.Steps.Common/Private/Test-IdleProviderMethodParameter.ps1 @@ -0,0 +1,59 @@ +# Tests whether a provider method supports a given parameter. +# Supports ScriptMethod (AST inspection) and compiled methods (reflection). + +function Test-IdleProviderMethodParameter { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [System.Management.Automation.PSMethodInfo] $ProviderMethod, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ParameterName + ) + + # 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 + } + } + } + } + } + return $false + } + + # For compiled methods (PSMethod, CodeMethod), use reflection + if ($ProviderMethod.MemberType -in @('Method', 'CodeMethod')) { + try { + # Get the method info via reflection + $methodInfo = $ProviderMethod.OverloadDefinitions + if ($null -ne $methodInfo) { + # Check if any overload contains the parameter name + foreach ($overload in $methodInfo) { + if ($overload -match "\b$ParameterName\b") { + return $true + } + } + } + } + catch { + # If reflection fails, assume parameter is not supported + Write-Verbose "Could not inspect compiled method parameters: $_" + } + return $false + } + + # Unknown method type + return $false +} diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 index 8710d74..8460eab 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepCreateIdentity.ps1 @@ -11,6 +11,14 @@ function Invoke-IdleStepCreateIdentity { The step is idempotent by design: if the identity already exists, the provider should return Changed = $false without creating a duplicate. + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -61,8 +69,12 @@ function Invoke-IdleStepCreateIdentity { throw "Provider '$providerAlias' was not supplied by the host." } - $provider = $Context.Providers[$providerAlias] - $result = $provider.CreateIdentity([string]$with.IdentityKey, $with.Attributes) + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'CreateIdentity' ` + -MethodArguments @([string]$with.IdentityKey, $with.Attributes) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 index 3fce4a7..3debd5e 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDeleteIdentity.ps1 @@ -15,6 +15,14 @@ function Invoke-IdleStepDeleteIdentity { capability, which is typically opt-in for safety. The provider must be configured to allow deletion (e.g., AllowDelete = $true for AD provider). + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -58,8 +66,12 @@ function Invoke-IdleStepDeleteIdentity { throw "Provider '$providerAlias' was not supplied by the host." } - $provider = $Context.Providers[$providerAlias] - $result = $provider.DeleteIdentity([string]$with.IdentityKey) + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'DeleteIdentity' ` + -MethodArguments @([string]$with.IdentityKey) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 index 5fcdaa9..fdd19ce 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepDisableIdentity.ps1 @@ -11,6 +11,14 @@ function Invoke-IdleStepDisableIdentity { The step is idempotent by design: if the identity is already disabled, the provider should return Changed = $false. + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -54,8 +62,12 @@ function Invoke-IdleStepDisableIdentity { throw "Provider '$providerAlias' was not supplied by the host." } - $provider = $Context.Providers[$providerAlias] - $result = $provider.DisableIdentity([string]$with.IdentityKey) + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'DisableIdentity' ` + -MethodArguments @([string]$with.IdentityKey) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 index 8a86af9..9ea7c36 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnableIdentity.ps1 @@ -11,6 +11,14 @@ function Invoke-IdleStepEnableIdentity { The step is idempotent by design: if the identity is already enabled, the provider should return Changed = $false. + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -54,8 +62,12 @@ function Invoke-IdleStepEnableIdentity { throw "Provider '$providerAlias' was not supplied by the host." } - $provider = $Context.Providers[$providerAlias] - $result = $provider.EnableIdentity([string]$with.IdentityKey) + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnableIdentity' ` + -MethodArguments @([string]$with.IdentityKey) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 index 31f6d4e..e784225 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttribute.ps1 @@ -11,6 +11,14 @@ function Invoke-IdleStepEnsureAttribute { The step is idempotent by design: it converges state to the desired value. + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -54,8 +62,12 @@ function Invoke-IdleStepEnsureAttribute { throw "Provider '$providerAlias' was not supplied by the host." } - $provider = $Context.Providers[$providerAlias] - $result = $provider.EnsureAttribute([string]$with.IdentityKey, [string]$with.Name, $with.Value) + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'EnsureAttribute' ` + -MethodArguments @([string]$with.IdentityKey, [string]$with.Name, $with.Value) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 index 39a2a9a..99a69cc 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureEntitlement.ps1 @@ -15,6 +15,14 @@ function Invoke-IdleStepEnsureEntitlement { The step is idempotent and only calls Grant/Revoke when the assignment needs to change. + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider methods + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -148,6 +156,19 @@ function Invoke-IdleStepEnsureEntitlement { throw "Provider '$providerAlias' was not supplied by the host." } + # Auth session acquisition (optional, data-only) + $authSession = $null + if ($with.ContainsKey('AuthSessionName')) { + $sessionName = [string]$with.AuthSessionName + $sessionOptions = if ($with.ContainsKey('AuthSessionOptions')) { $with.AuthSessionOptions } else { $null } + + if ($null -ne $sessionOptions -and -not ($sessionOptions -is [hashtable])) { + throw "With.AuthSessionOptions must be a hashtable or null." + } + + $authSession = $Context.AcquireAuthSession($sessionName, $sessionOptions) + } + $provider = $Context.Providers[$providerAlias] $requiredMethods = @('ListEntitlements') @@ -164,14 +185,29 @@ function Invoke-IdleStepEnsureEntitlement { } } - $current = @($provider.ListEntitlements($identityKey)) + # Check AuthSession support for each method + $listSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['ListEntitlements'] -ParameterName 'AuthSession' + $grantSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['GrantEntitlement'] -ParameterName 'AuthSession' + $revokeSupportsAuthSession = Test-IdleProviderMethodParameter -ProviderMethod $provider.PSObject.Methods['RevokeEntitlement'] -ParameterName 'AuthSession' + + if ($listSupportsAuthSession -and $null -ne $authSession) { + $current = @($provider.ListEntitlements($identityKey, $authSession)) + } + else { + $current = @($provider.ListEntitlements($identityKey)) + } $matches = @($current | Where-Object { Test-IdleStepEntitlementEquals -A $_ -B $entitlement }) $changed = $false if ($state -eq 'present') { if (@($matches).Count -eq 0) { - $result = $provider.GrantEntitlement($identityKey, $entitlement) + if ($grantSupportsAuthSession -and $null -ne $authSession) { + $result = $provider.GrantEntitlement($identityKey, $entitlement, $authSession) + } + else { + $result = $provider.GrantEntitlement($identityKey, $entitlement) + } if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { $changed = [bool]$result.Changed } @@ -182,7 +218,12 @@ function Invoke-IdleStepEnsureEntitlement { } else { if (@($matches).Count -gt 0) { - $result = $provider.RevokeEntitlement($identityKey, $entitlement) + if ($revokeSupportsAuthSession -and $null -ne $authSession) { + $result = $provider.RevokeEntitlement($identityKey, $entitlement, $authSession) + } + else { + $result = $provider.RevokeEntitlement($identityKey, $entitlement) + } if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { $changed = [bool]$result.Changed } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 index 7b841fe..e6043a0 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepMoveIdentity.ps1 @@ -11,6 +11,14 @@ function Invoke-IdleStepMoveIdentity { The step is idempotent by design: if the identity is already in the target container, the provider should return Changed = $false. + Authentication: + - If With.AuthSessionName is present, the step acquires an auth session via + Context.AcquireAuthSession(Name, Options) and passes it to the provider method + if the provider supports an AuthSession parameter. + - With.AuthSessionOptions (optional, hashtable) is passed to the broker for + session selection (e.g., @{ Role = 'Tier0' }). + - ScriptBlocks in AuthSessionOptions are rejected (security boundary). + .PARAMETER Context Execution context created by IdLE.Core. @@ -57,8 +65,12 @@ function Invoke-IdleStepMoveIdentity { throw "Provider '$providerAlias' was not supplied by the host." } - $provider = $Context.Providers[$providerAlias] - $result = $provider.MoveIdentity([string]$with.IdentityKey, [string]$with.TargetContainer) + $result = Invoke-IdleProviderMethod ` + -Context $Context ` + -With $with ` + -ProviderAlias $providerAlias ` + -MethodName 'MoveIdentity' ` + -MethodArguments @([string]$with.IdentityKey, [string]$with.TargetContainer) $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 index a90af1d..36c5bb0 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -18,7 +18,8 @@ 'New-IdleLifecycleRequest', 'New-IdlePlan', 'Invoke-IdlePlan', - 'Export-IdlePlan' + 'Export-IdlePlan', + 'New-IdleAuthSessionBroker' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/tests/Invoke-IdleStepAuthSession.Tests.ps1 b/tests/Invoke-IdleStepAuthSession.Tests.ps1 new file mode 100644 index 0000000..6d50721 --- /dev/null +++ b/tests/Invoke-IdleStepAuthSession.Tests.ps1 @@ -0,0 +1,298 @@ +#requires -Version 7.0 + +Describe 'IdLE.Steps - Auth Session Routing' { + + BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/IdLE/IdLE.psd1') -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/IdLE.Core/IdLE.Core.psd1') -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/IdLE.Steps.Common/IdLE.Steps.Common.psd1') -Force + } + + Context 'EnsureAttribute - Auth Session Acquisition' { + + It 'acquires auth session when With.AuthSessionName is present' { + # Arrange + $testState = [pscustomobject]@{ + SessionAcquired = $false + AcquiredName = $null + AcquiredOptions = $null + } + + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + State = $testState + } + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + $this.State.SessionAcquired = $true + $this.State.AcquiredName = $Name + $this.State.AcquiredOptions = $Options + return [PSCredential]::new('testuser', (ConvertTo-SecureString 'testpass' -AsPlainText -Force)) + } -Force + + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + AuthSessionBroker = $broker + } + } + $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) + } -Force + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = @{ Role = 'Tier0' } + } + } + + # Act + $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.PSTypeNames | Should -Contain 'IdLE.StepResult' + $result.Status | Should -Be 'Completed' + $testState.SessionAcquired | Should -Be $true + $testState.AcquiredName | Should -Be 'ActiveDirectory' + $testState.AcquiredOptions.Role | Should -Be 'Tier0' + } + + It 'does not acquire auth session when With.AuthSessionName is absent' { + # Arrange + $sessionAcquired = $false + + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + } + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + $script:sessionAcquired = $true + throw "Should not be called" + } -Force + + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + AuthSessionBroker = $broker + } + } + $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) + } -Force + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + } + } + + # Act + $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.Status | Should -Be 'Completed' + $sessionAcquired | Should -Be $false + } + + It 'passes auth session to provider when provider supports AuthSession parameter' { + # Arrange + $testState = [pscustomobject]@{ + ReceivedAuthSession = $null + } + + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + } + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return [PSCredential]::new('tier0admin', (ConvertTo-SecureString 'pass123' -AsPlainText -Force)) + } -Force + + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + State = $testState + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value, $AuthSession) + $this.State.ReceivedAuthSession = $AuthSession + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + AuthSessionBroker = $broker + } + } + $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) + } -Force + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + AuthSessionName = 'ActiveDirectory' + } + } + + # Act + $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.Status | Should -Be 'Completed' + $testState.ReceivedAuthSession | Should -Not -BeNullOrEmpty + $testState.ReceivedAuthSession | Should -BeOfType [PSCredential] + $testState.ReceivedAuthSession.UserName | Should -Be 'tier0admin' + } + + It 'falls back to legacy signature when provider lacks AuthSession parameter' { + # Arrange + $testState = [pscustomobject]@{ + LegacyCallMade = $false + } + + $broker = [pscustomobject]@{ + PSTypeName = 'Tests.AuthSessionBroker' + } + $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return [PSCredential]::new('tier0admin', (ConvertTo-SecureString 'pass123' -AsPlainText -Force)) + } -Force + + # Provider without AuthSession parameter (legacy) + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + State = $testState + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value) + $this.State.LegacyCallMade = $true + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + AuthSessionBroker = $broker + } + } + $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { + param($Name, $Options) + return $this.Providers.AuthSessionBroker.AcquireAuthSession($Name, $Options) + } -Force + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + AuthSessionName = 'ActiveDirectory' + } + } + + # Act + $result = Invoke-IdleStepEnsureAttribute -Context $context -Step $step + + # Assert + $result | Should -Not -BeNullOrEmpty + $result.Status | Should -Be 'Completed' + $testState.LegacyCallMade | Should -Be $true + } + + It 'throws when With.AuthSessionOptions is not a hashtable' { + # Arrange + $mockProvider = [pscustomobject]@{ + PSTypeName = 'Tests.MockProvider' + } + $mockProvider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { + param($IdentityKey, $Name, $Value) + return [pscustomobject]@{ + PSTypeName = 'IdLE.ProviderResult' + Changed = $true + } + } -Force + + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Providers = @{ + Identity = $mockProvider + } + } + + $step = [pscustomobject]@{ + PSTypeName = 'IdLE.Step' + Name = 'TestStep' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'testuser' + Name = 'Department' + Value = 'IT' + AuthSessionName = 'ActiveDirectory' + AuthSessionOptions = 'invalid-string' + } + } + + # Act & Assert + { Invoke-IdleStepEnsureAttribute -Context $context -Step $step } | + Should -Throw '*AuthSessionOptions*hashtable*' + } + } +}