Modules/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1
|
function New-IdleADIdentityProvider { <# .SYNOPSIS Creates an Active Directory identity provider for IdLE. .DESCRIPTION This provider integrates with on-premises Active Directory environments. It requires the ActiveDirectory PowerShell module (RSAT) and runs on Windows only. The provider supports common identity operations (Create, Read, Disable, Enable, Move, Delete) and group entitlement management (List, Grant, Revoke). Identity addressing supports: - GUID (ObjectGuid) - pattern: ^[0-9a-fA-F-]{36}$ or N-format - UPN (UserPrincipalName) - contains @ - sAMAccountName - default fallback 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. When $true, the provider advertises the Delete capability and allows identity deletion. Default is $false for safety. .PARAMETER Adapter Internal parameter for dependency injection during testing. Allows unit tests to inject 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 $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 # 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()] [switch] $AllowDelete, [Parameter()] [AllowNull()] [object] $Adapter ) # Check prerequisites and emit warnings if required components are missing $prereqs = Test-IdleADPrerequisites if (-not $prereqs.IsHealthy) { foreach ($missing in $prereqs.MissingRequired) { Write-Warning "AD provider prerequisite check: Required component '$missing' is not available." } foreach ($note in $prereqs.Notes) { Write-Warning "AD provider prerequisite check: $note" } } if ($null -eq $Adapter) { $Adapter = New-IdleADAdapter } $convertToEntitlement = { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Value ) return ConvertTo-IdleADEntitlement -Value $Value } $testEntitlementEquals = { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $A, [Parameter(Mandatory)] [ValidateNotNull()] [object] $B ) $aEnt = $this.ConvertToEntitlement($A) $bEnt = $this.ConvertToEntitlement($B) if ($aEnt.Kind -ne $bEnt.Kind) { return $false } return [string]::Equals($aEnt.Id, $bEnt.Id, [System.StringComparison]::OrdinalIgnoreCase) } $resolveIdentity = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [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 = $adapter.GetUserByGuid($guid.ToString()) } catch [System.Management.Automation.MethodException] { Write-Verbose "GetUserByGuid failed for GUID '$IdentityKey': $_" $user = $null } if ($null -ne $user) { return $user } throw "Identity with GUID '$IdentityKey' not found." } # Try UPN format (contains @) if ($IdentityKey -match '@') { $user = $adapter.GetUserByUpn($IdentityKey) if ($null -ne $user) { return $user } throw "Identity with UPN '$IdentityKey' not found." } # Fallback to sAMAccountName $user = $adapter.GetUserBySam($IdentityKey) if ($null -ne $user) { return $user } throw "Identity with sAMAccountName '$IdentityKey' not found." } $normalizeGroupId = { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $GroupId, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $group = $adapter.GetGroupById($GroupId) if ($null -eq $group) { throw "Group '$GroupId' not found." } return $group.DistinguishedName } $provider = [pscustomobject]@{ PSTypeName = 'IdLE.Provider.ADIdentityProvider' Name = 'ADIdentityProvider' Adapter = $Adapter AllowDelete = [bool]$AllowDelete } # Helper method to extract credential from AuthSession and create effective adapter $getEffectiveAdapter = { param( [Parameter()] [AllowNull()] [object] $AuthSession ) # If no AuthSession, return the default adapter # Only validate prerequisites for the default adapter if it's the real one (not injected for tests) # Check TypeNames collection (PSTypeName in hashtable adds to TypeNames, not as a property) if ($null -eq $AuthSession) { $isRealAdapter = ($this.Adapter.PSObject.TypeNames -contains 'IdLE.ADAdapter') if ($isRealAdapter) { $prereqCheck = Test-IdleADPrerequisites if (-not $prereqCheck.IsHealthy) { $missingList = $prereqCheck.MissingRequired -join ', ' $errorMsg = "AD provider operation cannot proceed. Required prerequisite(s) missing: $missingList" if ($prereqCheck.Notes.Count -gt 0) { $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") } throw $errorMsg } } 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) { # Creating new adapter with credential - validate prerequisites $prereqCheck = Test-IdleADPrerequisites if (-not $prereqCheck.IsHealthy) { $missingList = $prereqCheck.MissingRequired -join ', ' $errorMsg = "AD provider operation cannot proceed. Required prerequisite(s) missing: $missingList" if ($prereqCheck.Notes.Count -gt 0) { $errorMsg += "`n" + ($prereqCheck.Notes -join "`n") } throw $errorMsg } 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 $provider | Add-Member -MemberType ScriptMethod -Name NormalizeGroupId -Value $normalizeGroupId -Force $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { $caps = @( 'IdLE.Identity.Read' 'IdLE.Identity.List' 'IdLE.Identity.Create' 'IdLE.Identity.Attribute.Ensure' 'IdLE.Identity.Move' 'IdLE.Identity.Disable' 'IdLE.Identity.Enable' 'IdLE.Entitlement.List' 'IdLE.Entitlement.Grant' 'IdLE.Entitlement.Revoke' ) if ($this.AllowDelete) { $caps += 'IdLE.Identity.Delete' } return $caps } -Force $provider | Add-Member -MemberType ScriptMethod -Name GetIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) # Validate adapter is available $this.GetEffectiveAdapter($AuthSession) | Out-Null $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $attributes = @{} if ($null -ne $user.GivenName) { $attributes['GivenName'] = $user.GivenName } if ($null -ne $user.Surname) { $attributes['Surname'] = $user.Surname } if ($null -ne $user.DisplayName) { $attributes['DisplayName'] = $user.DisplayName } if ($null -ne $user.Description) { $attributes['Description'] = $user.Description } if ($null -ne $user.Department) { $attributes['Department'] = $user.Department } if ($null -ne $user.Title) { $attributes['Title'] = $user.Title } if ($null -ne $user.EmailAddress) { $attributes['EmailAddress'] = $user.EmailAddress } if ($null -ne $user.UserPrincipalName) { $attributes['UserPrincipalName'] = $user.UserPrincipalName } if ($null -ne $user.sAMAccountName) { $attributes['sAMAccountName'] = $user.sAMAccountName } if ($null -ne $user.DistinguishedName) { $attributes['DistinguishedName'] = $user.DistinguishedName } return [pscustomobject]@{ PSTypeName = 'IdLE.Identity' IdentityKey = $IdentityKey Enabled = [bool]$user.Enabled Attributes = $attributes } } -Force $provider | Add-Member -MemberType ScriptMethod -Name ListIdentities -Value { param( [Parameter()] [hashtable] $Filter, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $users = $adapter.ListUsers($Filter) $identityKeys = @() foreach ($user in $users) { $identityKeys += $user.ObjectGuid.ToString() } return $identityKeys } -Force $provider | Add-Member -MemberType ScriptMethod -Name CreateIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNull()] [hashtable] $Attributes, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) try { $existing = $this.ResolveIdentity($IdentityKey, $AuthSession) if ($null -ne $existing) { return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' IdentityKey = $IdentityKey Changed = $false } } } catch { # Identity does not exist, proceed with creation (expected for idempotent create) Write-Verbose "Identity '$IdentityKey' does not exist, proceeding with creation" } $enabled = $true if ($Attributes.ContainsKey('Enabled')) { $enabled = [bool]$Attributes['Enabled'] } $null = $adapter.NewUser($IdentityKey, $Attributes, $enabled) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' IdentityKey = $IdentityKey Changed = $true } } -Force $provider | Add-Member -MemberType ScriptMethod -Name DeleteIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [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, $AuthSession) $adapter.DeleteUser($user.DistinguishedName) return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'DeleteIdentity' IdentityKey = $IdentityKey Changed = $true } } catch { # Check if identity doesn't exist (idempotent delete) # Use exception type if available, otherwise fall back to message check $isNotFound = $false if ($_.Exception.GetType().FullName -eq 'Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException') { $isNotFound = $true } elseif ($_.Exception.Message -match 'not found|cannot be found|does not exist') { $isNotFound = $true } if ($isNotFound) { return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'DeleteIdentity' IdentityKey = $IdentityKey Changed = $false } } throw } } -Force $provider | Add-Member -MemberType ScriptMethod -Name EnsureAttribute -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Name, [Parameter()] [AllowNull()] [object] $Value, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $currentValue = $null if ($user.PSObject.Properties.Name -contains $Name) { $currentValue = $user.$Name } $changed = $false if ($currentValue -ne $Value) { $adapter.SetUser($user.DistinguishedName, $Name, $Value) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnsureAttribute' IdentityKey = $IdentityKey Changed = $changed Name = $Name Value = $Value } } -Force $provider | Add-Member -MemberType ScriptMethod -Name MoveIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $TargetContainer, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $currentOu = $user.DistinguishedName -replace '^CN=[^,]+,', '' $changed = $false if ($currentOu -ne $TargetContainer) { $adapter.MoveObject($user.DistinguishedName, $TargetContainer) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'MoveIdentity' IdentityKey = $IdentityKey Changed = $changed TargetContainer = $TargetContainer } } -Force $provider | Add-Member -MemberType ScriptMethod -Name DisableIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $changed = $false if ($user.Enabled -ne $false) { $adapter.DisableUser($user.DistinguishedName) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'DisableIdentity' IdentityKey = $IdentityKey Changed = $changed } } -Force $provider | Add-Member -MemberType ScriptMethod -Name EnableIdentity -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $changed = $false if ($user.Enabled -ne $true) { $adapter.EnableUser($user.DistinguishedName) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'EnableIdentity' IdentityKey = $IdentityKey Changed = $changed } } -Force $provider | Add-Member -MemberType ScriptMethod -Name ListEntitlements -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) $groups = $adapter.GetUserGroups($user.DistinguishedName) $result = @() foreach ($group in $groups) { $result += [pscustomobject]@{ PSTypeName = 'IdLE.Entitlement' Kind = 'Group' Id = $group.DistinguishedName DisplayName = $group.Name } } return $result } -Force $provider | Add-Member -MemberType ScriptMethod -Name GrantEntitlement -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Entitlement, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $normalized = $this.ConvertToEntitlement($Entitlement) $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) { $adapter.AddGroupMember($groupDn, $user.DistinguishedName) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'GrantEntitlement' IdentityKey = $IdentityKey Changed = $changed Entitlement = $normalized } } -Force $provider | Add-Member -MemberType ScriptMethod -Name RevokeEntitlement -Value { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNull()] [object] $Entitlement, [Parameter()] [AllowNull()] [object] $AuthSession ) $adapter = $this.GetEffectiveAdapter($AuthSession) $normalized = $this.ConvertToEntitlement($Entitlement) $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) { $adapter.RemoveGroupMember($groupDn, $user.DistinguishedName) $changed = $true } return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'RevokeEntitlement' IdentityKey = $IdentityKey Changed = $changed Entitlement = $normalized } } -Force return $provider } |