From 884fb964353c385557eb0f8ac61af86faafa0e25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:51:59 +0000 Subject: [PATCH 1/8] Initial plan From b05aefb8fd836f2ad32c09a21eb5bd83e8419463 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:55:49 +0000 Subject: [PATCH 2/8] Add attribute contract definitions and strict validation for AD Provider Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Get-IdleADAttributeContract.ps1 | 86 ++++++++++++ .../Private/New-IdleADAdapter.ps1 | 8 ++ .../Private/Test-IdleADAttributeContract.ps1 | 123 ++++++++++++++++++ .../Public/New-IdleADIdentityProvider.ps1 | 28 ++++ 4 files changed, 245 insertions(+) create mode 100644 src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 create mode 100644 src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 new file mode 100644 index 00000000..bbcc5078 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -0,0 +1,86 @@ +function Get-IdleADAttributeContract { + <# + .SYNOPSIS + Returns the supported attribute contract for AD Provider operations. + + .DESCRIPTION + Defines which attributes are supported for CreateIdentity and EnsureAttribute operations. + This contract serves as the single source of truth for attribute validation. + + .PARAMETER Operation + The operation to get the contract for: 'CreateIdentity' or 'EnsureAttribute'. + + .OUTPUTS + System.Collections.Hashtable + Returns a hashtable where keys are supported attribute names and values contain metadata. + + .EXAMPLE + $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' + $supportedKeys = $contract.Keys + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory)] + [ValidateSet('CreateIdentity', 'EnsureAttribute')] + [string] $Operation + ) + + if ($Operation -eq 'CreateIdentity') { + return @{ + # Identity Attributes + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Name Attributes + Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Organizational Attributes + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Contact Attributes + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Relationship Attributes + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # Password Attributes + AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + + # State Attributes + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + + # Extension Container + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } + } + } + elseif ($Operation -eq 'EnsureAttribute') { + return @{ + # Name Attributes + GivenName = @{ Target = 'Parameter'; Type = 'String' } + Surname = @{ Target = 'Parameter'; Type = 'String' } + DisplayName = @{ Target = 'Parameter'; Type = 'String' } + + # Organizational Attributes + Description = @{ Target = 'Parameter'; Type = 'String' } + Department = @{ Target = 'Parameter'; Type = 'String' } + Title = @{ Target = 'Parameter'; Type = 'String' } + + # Contact Attributes + EmailAddress = @{ Target = 'Parameter'; Type = 'String' } + + # Identity Attributes + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String' } + + # Relationship Attributes + Manager = @{ Target = 'Parameter'; Type = 'String' } + } + } +} diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 34004851..bd3000f9 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -405,6 +405,14 @@ function New-IdleADAdapter { $params['AccountPassword'] = ConvertTo-SecureString -String $plainTextPassword -AsPlainText -Force } + # Handle OtherAttributes for custom LDAP attributes + if ($effectiveAttributes.ContainsKey('OtherAttributes')) { + $otherAttrs = $effectiveAttributes['OtherAttributes'] + if ($null -ne $otherAttrs -and $otherAttrs.Count -gt 0) { + $params['OtherAttributes'] = $otherAttrs + } + } + if ($null -ne $this.Credential) { $params['Credential'] = $this.Credential } diff --git a/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 new file mode 100644 index 00000000..5d3ddb95 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 @@ -0,0 +1,123 @@ +function Test-IdleADAttributeContract { + <# + .SYNOPSIS + Validates attributes against the AD Provider attribute contract. + + .DESCRIPTION + Performs strict validation of provided attributes against the supported attribute contract. + Throws an exception if unsupported attributes are detected. + + .PARAMETER Attributes + Hashtable of attributes to validate. + + .PARAMETER Operation + The operation context: 'CreateIdentity' or 'EnsureAttribute'. + + .PARAMETER AttributeName + For EnsureAttribute, the specific attribute name being set. + + .OUTPUTS + System.Collections.Hashtable + Returns a hashtable with validation results: + - Requested: array of requested attribute keys + - Supported: array of supported attribute keys + - Unsupported: array of unsupported attribute keys + + .EXAMPLE + $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' + # Throws if unsupported attributes found + + .EXAMPLE + $result = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' + # Throws if attribute not supported for EnsureAttribute + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter()] + [AllowNull()] + [hashtable] $Attributes, + + [Parameter(Mandatory)] + [ValidateSet('CreateIdentity', 'EnsureAttribute')] + [string] $Operation, + + [Parameter()] + [string] $AttributeName + ) + + $contract = Get-IdleADAttributeContract -Operation $Operation + + if ($Operation -eq 'CreateIdentity') { + if ($null -eq $Attributes) { + return @{ + Requested = @() + Supported = @() + Unsupported = @() + } + } + + $requestedKeys = @($Attributes.Keys) + $supportedKeys = @($contract.Keys) + $unsupportedKeys = @($requestedKeys | Where-Object { $_ -notin $supportedKeys }) + + if ($unsupportedKeys.Count -gt 0) { + $errorMessage = "AD Provider: Unsupported attributes in CreateIdentity operation.`n" + $errorMessage += "Unsupported attributes: $($unsupportedKeys -join ', ')`n`n" + $errorMessage += "Supported attributes for CreateIdentity:`n" + $errorMessage += " - Identity: SamAccountName, UserPrincipalName, Path`n" + $errorMessage += " - Name: Name, GivenName, Surname, DisplayName`n" + $errorMessage += " - Organization: Description, Department, Title`n" + $errorMessage += " - Contact: EmailAddress`n" + $errorMessage += " - Relationship: Manager`n" + $errorMessage += " - Password: AccountPassword, AccountPasswordAsPlainText`n" + $errorMessage += " - State: Enabled`n" + $errorMessage += " - Extension: OtherAttributes (hashtable of LDAP attributes)`n`n" + $errorMessage += "To set custom LDAP attributes, use the 'OtherAttributes' container." + + throw $errorMessage + } + + # Validate OtherAttributes if present + if ($Attributes.ContainsKey('OtherAttributes')) { + $otherAttrs = $Attributes['OtherAttributes'] + if ($null -ne $otherAttrs -and $otherAttrs -isnot [hashtable]) { + throw "AD Provider: 'OtherAttributes' must be a hashtable. Received type: $($otherAttrs.GetType().FullName)" + } + } + + return @{ + Requested = $requestedKeys + Supported = @($requestedKeys | Where-Object { $_ -in $supportedKeys }) + Unsupported = $unsupportedKeys + } + } + elseif ($Operation -eq 'EnsureAttribute') { + if ([string]::IsNullOrWhiteSpace($AttributeName)) { + throw "AD Provider: AttributeName is required for EnsureAttribute validation." + } + + $supportedKeys = @($contract.Keys) + + if ($AttributeName -notin $supportedKeys) { + $errorMessage = "AD Provider: Unsupported attribute in EnsureAttribute operation.`n" + $errorMessage += "Attribute: $AttributeName`n`n" + $errorMessage += "Supported attributes for EnsureAttribute:`n" + $errorMessage += " - Name: GivenName, Surname, DisplayName`n" + $errorMessage += " - Organization: Description, Department, Title`n" + $errorMessage += " - Contact: EmailAddress`n" + $errorMessage += " - Identity: UserPrincipalName`n" + $errorMessage += " - Relationship: Manager`n`n" + $errorMessage += "Note: Custom LDAP attributes are not supported in EnsureAttribute.`n" + $errorMessage += "For custom attributes, use CreateIdentity with OtherAttributes or direct provider methods." + + throw $errorMessage + } + + return @{ + Requested = @($AttributeName) + Supported = @($AttributeName) + Unsupported = @() + } + } +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 69a5659b..cce29c75 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -361,6 +361,9 @@ function New-IdleADIdentityProvider { [object] $AuthSession ) + # Validate attributes against contract (strict mode - will throw on unsupported attributes) + $validationResult = Test-IdleADAttributeContract -Attributes $Attributes -Operation 'CreateIdentity' + $adapter = $this.GetEffectiveAdapter($AuthSession) try { @@ -386,6 +389,17 @@ function New-IdleADIdentityProvider { $null = $adapter.NewUser($IdentityKey, $Attributes, $enabled) + # Emit observability event + if ($null -ne $this.EventSink) { + $eventData = @{ + IdentityKey = $IdentityKey + Requested = $validationResult.Requested + Applied = $validationResult.Supported + Ignored = $validationResult.Unsupported + } + $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesApplied', 'Attributes applied during identity creation', 'CreateIdentity', $eventData) + } + return [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' @@ -463,6 +477,9 @@ function New-IdleADIdentityProvider { [object] $AuthSession ) + # Validate attribute against contract (strict mode - will throw on unsupported attributes) + $validationResult = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $Name + $adapter = $this.GetEffectiveAdapter($AuthSession) $user = $this.ResolveIdentity($IdentityKey, $AuthSession) @@ -476,6 +493,17 @@ function New-IdleADIdentityProvider { if ($currentValue -ne $Value) { $adapter.SetUser($user.DistinguishedName, $Name, $Value) $changed = $true + + # Emit observability event + if ($null -ne $this.EventSink) { + $eventData = @{ + IdentityKey = $IdentityKey + AttributeName = $Name + OldValue = $currentValue + NewValue = $Value + } + $this.EventSink.WriteEvent('Provider.AD.EnsureAttribute.AttributeChanged', "Attribute '$Name' changed", 'EnsureAttribute', $eventData) + } } return [pscustomobject]@{ From 895274b9df2900fa9ce576fa50ba89b35b003672 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:59:37 +0000 Subject: [PATCH 3/8] Fix EventSink property check and update contract test to use 'Description' Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleADIdentityProvider.ps1 | 4 +- .../IdentityProvider.Contract.ps1 | 3 +- tests/Providers/ADAttributeContract.Tests.ps1 | 291 ++++++++++++++++++ 3 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 tests/Providers/ADAttributeContract.Tests.ps1 diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index cce29c75..e1ee2cd1 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -390,7 +390,7 @@ function New-IdleADIdentityProvider { $null = $adapter.NewUser($IdentityKey, $Attributes, $enabled) # Emit observability event - if ($null -ne $this.EventSink) { + if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) { $eventData = @{ IdentityKey = $IdentityKey Requested = $validationResult.Requested @@ -495,7 +495,7 @@ function New-IdleADIdentityProvider { $changed = $true # Emit observability event - if ($null -ne $this.EventSink) { + if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) { $eventData = @{ IdentityKey = $IdentityKey AttributeName = $Name diff --git a/tests/ProviderContracts/IdentityProvider.Contract.ps1 b/tests/ProviderContracts/IdentityProvider.Contract.ps1 index de760c6c..0ea30b9c 100644 --- a/tests/ProviderContracts/IdentityProvider.Contract.ps1 +++ b/tests/ProviderContracts/IdentityProvider.Contract.ps1 @@ -66,7 +66,8 @@ function Invoke-IdleIdentityProviderContractTests { It 'EnsureAttribute returns a stable result shape' { $id = "contract-$([guid]::NewGuid().ToString('N'))" - $result = $script:Provider.EnsureAttribute($id, 'contractKey', 'contractValue') + # Use 'Description' - a common attribute supported by most identity providers + $result = $script:Provider.EnsureAttribute($id, 'Description', 'contractValue') $result | Should -Not -BeNullOrEmpty $result.PSObject.Properties.Name | Should -Contain 'Changed' diff --git a/tests/Providers/ADAttributeContract.Tests.ps1 b/tests/Providers/ADAttributeContract.Tests.ps1 new file mode 100644 index 00000000..ac8d2b3a --- /dev/null +++ b/tests/Providers/ADAttributeContract.Tests.ps1 @@ -0,0 +1,291 @@ +Set-StrictMode -Version Latest + +BeforeDiscovery { + . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'AD Provider Attribute Contract' { + BeforeAll { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $adProviderPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.AD\IdLE.Provider.AD.psd1' + + if (Test-Path -LiteralPath $adProviderPath -PathType Leaf) { + Import-Module $adProviderPath -Force + } + } + + Context 'Get-IdleADAttributeContract' { + It 'Returns contract for CreateIdentity operation' { + $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' + + $contract | Should -Not -BeNullOrEmpty + $contract | Should -BeOfType [hashtable] + $contract.Keys | Should -Contain 'GivenName' + $contract.Keys | Should -Contain 'Surname' + $contract.Keys | Should -Contain 'OtherAttributes' + } + + It 'Returns contract for EnsureAttribute operation' { + $contract = Get-IdleADAttributeContract -Operation 'EnsureAttribute' + + $contract | Should -Not -BeNullOrEmpty + $contract | Should -BeOfType [hashtable] + $contract.Keys | Should -Contain 'GivenName' + $contract.Keys | Should -Contain 'Surname' + } + + It 'CreateIdentity contract includes all expected attributes' { + $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' + + # Identity attributes + $contract.Keys | Should -Contain 'SamAccountName' + $contract.Keys | Should -Contain 'UserPrincipalName' + $contract.Keys | Should -Contain 'Path' + + # Name attributes + $contract.Keys | Should -Contain 'Name' + $contract.Keys | Should -Contain 'GivenName' + $contract.Keys | Should -Contain 'Surname' + $contract.Keys | Should -Contain 'DisplayName' + + # Organizational attributes + $contract.Keys | Should -Contain 'Description' + $contract.Keys | Should -Contain 'Department' + $contract.Keys | Should -Contain 'Title' + + # Contact attributes + $contract.Keys | Should -Contain 'EmailAddress' + + # Relationship attributes + $contract.Keys | Should -Contain 'Manager' + + # Password attributes + $contract.Keys | Should -Contain 'AccountPassword' + $contract.Keys | Should -Contain 'AccountPasswordAsPlainText' + + # State attributes + $contract.Keys | Should -Contain 'Enabled' + + # Extension container + $contract.Keys | Should -Contain 'OtherAttributes' + } + + It 'EnsureAttribute contract excludes password and OtherAttributes' { + $contract = Get-IdleADAttributeContract -Operation 'EnsureAttribute' + + $contract.Keys | Should -Not -Contain 'AccountPassword' + $contract.Keys | Should -Not -Contain 'AccountPasswordAsPlainText' + $contract.Keys | Should -Not -Contain 'OtherAttributes' + $contract.Keys | Should -Not -Contain 'Path' + $contract.Keys | Should -Not -Contain 'Name' + $contract.Keys | Should -Not -Contain 'Enabled' + } + + It 'EnsureAttribute contract includes modifiable attributes' { + $contract = Get-IdleADAttributeContract -Operation 'EnsureAttribute' + + # Name attributes + $contract.Keys | Should -Contain 'GivenName' + $contract.Keys | Should -Contain 'Surname' + $contract.Keys | Should -Contain 'DisplayName' + + # Organizational attributes + $contract.Keys | Should -Contain 'Description' + $contract.Keys | Should -Contain 'Department' + $contract.Keys | Should -Contain 'Title' + + # Contact attributes + $contract.Keys | Should -Contain 'EmailAddress' + + # Identity attributes + $contract.Keys | Should -Contain 'UserPrincipalName' + + # Relationship attributes + $contract.Keys | Should -Contain 'Manager' + } + } + + Context 'Test-IdleADAttributeContract - CreateIdentity' { + It 'Validates supported attributes without error' { + $attrs = @{ + GivenName = 'John' + Surname = 'Doe' + DisplayName = 'John Doe' + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | Should -Not -Throw + } + + It 'Returns correct validation result for supported attributes' { + $attrs = @{ + GivenName = 'John' + Surname = 'Doe' + DisplayName = 'John Doe' + } + + $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' + + $result.Requested | Should -HaveCount 3 + $result.Supported | Should -HaveCount 3 + $result.Unsupported | Should -HaveCount 0 + } + + It 'Throws on unsupported attribute' { + $attrs = @{ + GivenName = 'John' + InvalidAttribute = 'Value' + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | + Should -Throw -ExpectedMessage '*Unsupported attributes*' + } + + It 'Error message lists unsupported attributes' { + $attrs = @{ + InvalidAttr1 = 'Value1' + InvalidAttr2 = 'Value2' + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | + Should -Throw -ExpectedMessage '*InvalidAttr1*InvalidAttr2*' + } + + It 'Error message provides guidance on supported attributes' { + $attrs = @{ + InvalidAttribute = 'Value' + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | + Should -Throw -ExpectedMessage '*Supported attributes for CreateIdentity*' + } + + It 'Accepts OtherAttributes as hashtable' { + $attrs = @{ + GivenName = 'John' + OtherAttributes = @{ + extensionAttribute1 = 'X' + employeeType = 'Contractor' + } + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | Should -Not -Throw + } + + It 'Throws if OtherAttributes is not a hashtable' { + $attrs = @{ + GivenName = 'John' + OtherAttributes = 'NotAHashtable' + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | + Should -Throw -ExpectedMessage '*OtherAttributes*must be a hashtable*' + } + + It 'Handles empty attributes hashtable' { + $attrs = @{} + + $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' + + $result.Requested | Should -HaveCount 0 + $result.Supported | Should -HaveCount 0 + $result.Unsupported | Should -HaveCount 0 + } + + It 'Handles null attributes hashtable' { + $result = Test-IdleADAttributeContract -Attributes $null -Operation 'CreateIdentity' + + $result.Requested | Should -HaveCount 0 + $result.Supported | Should -HaveCount 0 + $result.Unsupported | Should -HaveCount 0 + } + + It 'Validates all supported CreateIdentity attributes' { + $attrs = @{ + SamAccountName = 'jdoe' + UserPrincipalName = 'jdoe@example.com' + Path = 'OU=Users,DC=example,DC=com' + Name = 'John Doe' + GivenName = 'John' + Surname = 'Doe' + DisplayName = 'John Doe' + Description = 'Test User' + Department = 'IT' + Title = 'Engineer' + EmailAddress = 'john.doe@example.com' + Manager = 'CN=Manager,OU=Users,DC=example,DC=com' + Enabled = $true + } + + { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | Should -Not -Throw + + $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' + $result.Unsupported | Should -HaveCount 0 + } + } + + Context 'Test-IdleADAttributeContract - EnsureAttribute' { + It 'Validates supported attribute without error' { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'GivenName' } | Should -Not -Throw + } + + It 'Returns correct validation result for supported attribute' { + $result = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'GivenName' + + $result.Requested | Should -HaveCount 1 + $result.Requested[0] | Should -Be 'GivenName' + $result.Supported | Should -HaveCount 1 + $result.Supported[0] | Should -Be 'GivenName' + $result.Unsupported | Should -HaveCount 0 + } + + It 'Throws on unsupported attribute' { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttribute' } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + + It 'Error message lists the unsupported attribute' { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' } | + Should -Throw -ExpectedMessage '*InvalidAttr*' + } + + It 'Error message provides guidance on supported attributes' { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' } | + Should -Throw -ExpectedMessage '*Supported attributes for EnsureAttribute*' + } + + It 'Throws if AttributeName is empty' { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName '' } | + Should -Throw -ExpectedMessage '*AttributeName is required*' + } + + It 'Throws if AttributeName is null' { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $null } | + Should -Throw -ExpectedMessage '*AttributeName is required*' + } + + It 'Validates all supported EnsureAttribute attributes' { + $supportedAttrs = @( + 'GivenName', 'Surname', 'DisplayName', + 'Description', 'Department', 'Title', + 'EmailAddress', 'UserPrincipalName', 'Manager' + ) + + foreach ($attr in $supportedAttrs) { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $attr } | Should -Not -Throw + } + } + + It 'Rejects CreateIdentity-only attributes in EnsureAttribute' { + $createOnlyAttrs = @( + 'AccountPassword', 'AccountPasswordAsPlainText', + 'Path', 'Name', 'Enabled', 'OtherAttributes' + ) + + foreach ($attr in $createOnlyAttrs) { + { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $attr } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + } + } +} From b1ab3046516888428e0909bb01916fc4e608e054 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:01:15 +0000 Subject: [PATCH 4/8] Add attribute validation integration tests and update documentation Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 128 ++++++++++++++++++- tests/Providers/ADIdentityProvider.Tests.ps1 | 104 +++++++++++++++ 2 files changed, 230 insertions(+), 2 deletions(-) diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index ee26d9e8..adfe0100 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -255,10 +255,134 @@ This design ensures workflows can be re-run safely without causing duplicate ope --- +## Attribute contracts + +The AD provider enforces strict validation of attributes to ensure fail-fast behavior and prevent silent failures. + +### CreateIdentity - Supported attributes + +The following attributes are supported when creating identities via `CreateIdentity`: + +#### Identity attributes +- `SamAccountName` (string) - User logon name (pre-Windows 2000) +- `UserPrincipalName` (string) - User principal name (email-style logon) +- `Path` (string) - DistinguishedName of the OU where the user should be created + +#### Name attributes +- `Name` (string) - Full name (CN/RDN) +- `GivenName` (string) - First name +- `Surname` (string) - Last name +- `DisplayName` (string) - Display name + +#### Organizational attributes +- `Description` (string) - User description +- `Department` (string) - Department +- `Title` (string) - Job title + +#### Contact attributes +- `EmailAddress` (string) - Email address + +#### Relationship attributes +- `Manager` (string) - Manager DN, GUID, UPN, or sAMAccountName (auto-resolved to DN) + +#### Password attributes +- `AccountPassword` (SecureString or ProtectedString) - Password as SecureString or DPAPI-protected string +- `AccountPasswordAsPlainText` (string) - Plaintext password (explicit opt-in, automatically redacted in events) + +:::warning +Only one password attribute can be used at a time. Using both `AccountPassword` and `AccountPasswordAsPlainText` will throw an error. +::: + +#### State attributes +- `Enabled` (boolean) - Account enabled state (default: `$true`) + +#### Extension container +- `OtherAttributes` (hashtable) - Custom LDAP attributes not covered by named parameters + - Must be a hashtable + - Keys must be valid LDAP attribute names + - Values must be compatible with AD cmdlets (string, array of strings, etc.) + +**Example:** +```powershell +$attrs = @{ + GivenName = 'John' + Surname = 'Doe' + DisplayName = 'John Doe' + Department = 'IT' + Title = 'Engineer' + EmailAddress = 'john.doe@example.com' + OtherAttributes = @{ + extensionAttribute1 = 'CustomValue' + employeeType = 'Contractor' + } +} +``` + +### EnsureAttribute - Supported attributes + +The following attributes are supported when updating identities via `EnsureAttribute`: + +#### Name attributes +- `GivenName` (string) - First name +- `Surname` (string) - Last name +- `DisplayName` (string) - Display name + +#### Organizational attributes +- `Description` (string) - User description +- `Department` (string) - Department +- `Title` (string) - Job title + +#### Contact attributes +- `EmailAddress` (string) - Email address + +#### Identity attributes +- `UserPrincipalName` (string) - User principal name + +#### Relationship attributes +- `Manager` (string) - Manager DN, GUID, UPN, or sAMAccountName (auto-resolved to DN) + +:::info +**Note:** Custom LDAP attributes (via `OtherAttributes`) are not supported in `EnsureAttribute`. They can only be set during identity creation via `CreateIdentity`. + +Password, Path, Name, and Enabled attributes are also CreateIdentity-only and cannot be modified via `EnsureAttribute`. +::: + +### Validation behavior + +**Strict mode (default):** +- Unsupported attribute keys cause an immediate error +- Error messages list the unsupported attributes and provide guidance +- No silent attribute dropping + +**Example error:** +``` +AD Provider: Unsupported attributes in CreateIdentity operation. +Unsupported attributes: InvalidAttr1, InvalidAttr2 + +Supported attributes for CreateIdentity: + - Identity: SamAccountName, UserPrincipalName, Path + - Name: Name, GivenName, Surname, DisplayName + - Organization: Description, Department, Title + - Contact: EmailAddress + - Relationship: Manager + - Password: AccountPassword, AccountPasswordAsPlainText + - State: Enabled + - Extension: OtherAttributes (hashtable of LDAP attributes) + +To set custom LDAP attributes, use the 'OtherAttributes' container. +``` + +--- + ## Observability -- **Events emitted by provider (if any):** - - Steps emit events via the execution context; provider operations are traced through step events +- **Events emitted by provider:** + - `Provider.AD.CreateIdentity.AttributesApplied` - Emitted after identity creation with attribute details + - `Provider.AD.EnsureAttribute.AttributeChanged` - Emitted when an attribute is modified +- **Event data includes:** + - Requested attributes + - Applied attributes + - Old and new values (for attribute changes) - **Sensitive data redaction:** Credential objects and secure strings are not included in operation results or events --- diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 6127c700..38dcea8d 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -1493,4 +1493,108 @@ Describe 'AD identity provider' { { $script:ManagerTestAdapter.NewUser('managertest14', $attrs, $true) } | Should -Throw -ExpectedMessage '*Could not find user*' } } + + Context 'Attribute validation (strict mode)' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:ValidationTestProvider = $provider + $script:ValidationTestAdapter = $adapter + } + + It 'CreateIdentity throws when unsupported attribute is provided' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + InvalidAttribute = 'ShouldFail' + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest1', $attrs) } | + Should -Throw -ExpectedMessage '*Unsupported attributes*' + } + + It 'CreateIdentity error message lists unsupported attributes' { + $attrs = @{ + GivenName = 'Test' + InvalidAttr1 = 'Value1' + InvalidAttr2 = 'Value2' + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest2', $attrs) } | + Should -Throw -ExpectedMessage '*InvalidAttr1*' + } + + It 'CreateIdentity succeeds with all supported attributes' { + $attrs = @{ + SamAccountName = 'validationtest3' + UserPrincipalName = 'test3@example.com' + GivenName = 'Test' + Surname = 'User' + DisplayName = 'Test User' + Description = 'Test Description' + Department = 'IT' + Title = 'Engineer' + EmailAddress = 'test@example.com' + Enabled = $true + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest3', $attrs) } | Should -Not -Throw + } + + It 'CreateIdentity accepts OtherAttributes hashtable' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + OtherAttributes = @{ + extensionAttribute1 = 'CustomValue1' + employeeType = 'Contractor' + } + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest4', $attrs) } | Should -Not -Throw + } + + It 'CreateIdentity throws when OtherAttributes is not a hashtable' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + OtherAttributes = 'NotAHashtable' + } + + { $script:ValidationTestProvider.CreateIdentity('validationtest5', $attrs) } | + Should -Throw -ExpectedMessage '*OtherAttributes*must be a hashtable*' + } + + It 'EnsureAttribute throws when unsupported attribute is requested' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'InvalidAttribute', 'Value') } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + + It 'EnsureAttribute error message lists the unsupported attribute' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'CustomLdapAttr', 'Value') } | + Should -Throw -ExpectedMessage '*CustomLdapAttr*' + } + + It 'EnsureAttribute succeeds with supported attributes' { + $supportedAttrs = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName') + + foreach ($attr in $supportedAttrs) { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', $attr, 'TestValue') } | Should -Not -Throw + } + } + + It 'EnsureAttribute rejects CreateIdentity-only attributes' { + $createOnlyAttrs = @('Path', 'AccountPassword', 'AccountPasswordAsPlainText', 'Enabled') + + foreach ($attr in $createOnlyAttrs) { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', $attr, 'TestValue') } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + } + + It 'EnsureAttribute rejects OtherAttributes' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', @{}) } | + Should -Throw -ExpectedMessage '*Unsupported attribute*' + } + } } From 73dcac9756679be026182cbc540f27565424cde4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:03:14 +0000 Subject: [PATCH 5/8] Remove private function tests - validation tested via integration tests Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- tests/Providers/ADAttributeContract.Tests.ps1 | 291 ------------------ 1 file changed, 291 deletions(-) delete mode 100644 tests/Providers/ADAttributeContract.Tests.ps1 diff --git a/tests/Providers/ADAttributeContract.Tests.ps1 b/tests/Providers/ADAttributeContract.Tests.ps1 deleted file mode 100644 index ac8d2b3a..00000000 --- a/tests/Providers/ADAttributeContract.Tests.ps1 +++ /dev/null @@ -1,291 +0,0 @@ -Set-StrictMode -Version Latest - -BeforeDiscovery { - . (Join-Path -Path $PSScriptRoot -ChildPath '..\_testHelpers.ps1') - Import-IdleTestModule -} - -Describe 'AD Provider Attribute Contract' { - BeforeAll { - $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent - $adProviderPath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.AD\IdLE.Provider.AD.psd1' - - if (Test-Path -LiteralPath $adProviderPath -PathType Leaf) { - Import-Module $adProviderPath -Force - } - } - - Context 'Get-IdleADAttributeContract' { - It 'Returns contract for CreateIdentity operation' { - $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' - - $contract | Should -Not -BeNullOrEmpty - $contract | Should -BeOfType [hashtable] - $contract.Keys | Should -Contain 'GivenName' - $contract.Keys | Should -Contain 'Surname' - $contract.Keys | Should -Contain 'OtherAttributes' - } - - It 'Returns contract for EnsureAttribute operation' { - $contract = Get-IdleADAttributeContract -Operation 'EnsureAttribute' - - $contract | Should -Not -BeNullOrEmpty - $contract | Should -BeOfType [hashtable] - $contract.Keys | Should -Contain 'GivenName' - $contract.Keys | Should -Contain 'Surname' - } - - It 'CreateIdentity contract includes all expected attributes' { - $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' - - # Identity attributes - $contract.Keys | Should -Contain 'SamAccountName' - $contract.Keys | Should -Contain 'UserPrincipalName' - $contract.Keys | Should -Contain 'Path' - - # Name attributes - $contract.Keys | Should -Contain 'Name' - $contract.Keys | Should -Contain 'GivenName' - $contract.Keys | Should -Contain 'Surname' - $contract.Keys | Should -Contain 'DisplayName' - - # Organizational attributes - $contract.Keys | Should -Contain 'Description' - $contract.Keys | Should -Contain 'Department' - $contract.Keys | Should -Contain 'Title' - - # Contact attributes - $contract.Keys | Should -Contain 'EmailAddress' - - # Relationship attributes - $contract.Keys | Should -Contain 'Manager' - - # Password attributes - $contract.Keys | Should -Contain 'AccountPassword' - $contract.Keys | Should -Contain 'AccountPasswordAsPlainText' - - # State attributes - $contract.Keys | Should -Contain 'Enabled' - - # Extension container - $contract.Keys | Should -Contain 'OtherAttributes' - } - - It 'EnsureAttribute contract excludes password and OtherAttributes' { - $contract = Get-IdleADAttributeContract -Operation 'EnsureAttribute' - - $contract.Keys | Should -Not -Contain 'AccountPassword' - $contract.Keys | Should -Not -Contain 'AccountPasswordAsPlainText' - $contract.Keys | Should -Not -Contain 'OtherAttributes' - $contract.Keys | Should -Not -Contain 'Path' - $contract.Keys | Should -Not -Contain 'Name' - $contract.Keys | Should -Not -Contain 'Enabled' - } - - It 'EnsureAttribute contract includes modifiable attributes' { - $contract = Get-IdleADAttributeContract -Operation 'EnsureAttribute' - - # Name attributes - $contract.Keys | Should -Contain 'GivenName' - $contract.Keys | Should -Contain 'Surname' - $contract.Keys | Should -Contain 'DisplayName' - - # Organizational attributes - $contract.Keys | Should -Contain 'Description' - $contract.Keys | Should -Contain 'Department' - $contract.Keys | Should -Contain 'Title' - - # Contact attributes - $contract.Keys | Should -Contain 'EmailAddress' - - # Identity attributes - $contract.Keys | Should -Contain 'UserPrincipalName' - - # Relationship attributes - $contract.Keys | Should -Contain 'Manager' - } - } - - Context 'Test-IdleADAttributeContract - CreateIdentity' { - It 'Validates supported attributes without error' { - $attrs = @{ - GivenName = 'John' - Surname = 'Doe' - DisplayName = 'John Doe' - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | Should -Not -Throw - } - - It 'Returns correct validation result for supported attributes' { - $attrs = @{ - GivenName = 'John' - Surname = 'Doe' - DisplayName = 'John Doe' - } - - $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' - - $result.Requested | Should -HaveCount 3 - $result.Supported | Should -HaveCount 3 - $result.Unsupported | Should -HaveCount 0 - } - - It 'Throws on unsupported attribute' { - $attrs = @{ - GivenName = 'John' - InvalidAttribute = 'Value' - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | - Should -Throw -ExpectedMessage '*Unsupported attributes*' - } - - It 'Error message lists unsupported attributes' { - $attrs = @{ - InvalidAttr1 = 'Value1' - InvalidAttr2 = 'Value2' - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | - Should -Throw -ExpectedMessage '*InvalidAttr1*InvalidAttr2*' - } - - It 'Error message provides guidance on supported attributes' { - $attrs = @{ - InvalidAttribute = 'Value' - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | - Should -Throw -ExpectedMessage '*Supported attributes for CreateIdentity*' - } - - It 'Accepts OtherAttributes as hashtable' { - $attrs = @{ - GivenName = 'John' - OtherAttributes = @{ - extensionAttribute1 = 'X' - employeeType = 'Contractor' - } - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | Should -Not -Throw - } - - It 'Throws if OtherAttributes is not a hashtable' { - $attrs = @{ - GivenName = 'John' - OtherAttributes = 'NotAHashtable' - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | - Should -Throw -ExpectedMessage '*OtherAttributes*must be a hashtable*' - } - - It 'Handles empty attributes hashtable' { - $attrs = @{} - - $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' - - $result.Requested | Should -HaveCount 0 - $result.Supported | Should -HaveCount 0 - $result.Unsupported | Should -HaveCount 0 - } - - It 'Handles null attributes hashtable' { - $result = Test-IdleADAttributeContract -Attributes $null -Operation 'CreateIdentity' - - $result.Requested | Should -HaveCount 0 - $result.Supported | Should -HaveCount 0 - $result.Unsupported | Should -HaveCount 0 - } - - It 'Validates all supported CreateIdentity attributes' { - $attrs = @{ - SamAccountName = 'jdoe' - UserPrincipalName = 'jdoe@example.com' - Path = 'OU=Users,DC=example,DC=com' - Name = 'John Doe' - GivenName = 'John' - Surname = 'Doe' - DisplayName = 'John Doe' - Description = 'Test User' - Department = 'IT' - Title = 'Engineer' - EmailAddress = 'john.doe@example.com' - Manager = 'CN=Manager,OU=Users,DC=example,DC=com' - Enabled = $true - } - - { Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' } | Should -Not -Throw - - $result = Test-IdleADAttributeContract -Attributes $attrs -Operation 'CreateIdentity' - $result.Unsupported | Should -HaveCount 0 - } - } - - Context 'Test-IdleADAttributeContract - EnsureAttribute' { - It 'Validates supported attribute without error' { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'GivenName' } | Should -Not -Throw - } - - It 'Returns correct validation result for supported attribute' { - $result = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'GivenName' - - $result.Requested | Should -HaveCount 1 - $result.Requested[0] | Should -Be 'GivenName' - $result.Supported | Should -HaveCount 1 - $result.Supported[0] | Should -Be 'GivenName' - $result.Unsupported | Should -HaveCount 0 - } - - It 'Throws on unsupported attribute' { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttribute' } | - Should -Throw -ExpectedMessage '*Unsupported attribute*' - } - - It 'Error message lists the unsupported attribute' { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' } | - Should -Throw -ExpectedMessage '*InvalidAttr*' - } - - It 'Error message provides guidance on supported attributes' { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' } | - Should -Throw -ExpectedMessage '*Supported attributes for EnsureAttribute*' - } - - It 'Throws if AttributeName is empty' { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName '' } | - Should -Throw -ExpectedMessage '*AttributeName is required*' - } - - It 'Throws if AttributeName is null' { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $null } | - Should -Throw -ExpectedMessage '*AttributeName is required*' - } - - It 'Validates all supported EnsureAttribute attributes' { - $supportedAttrs = @( - 'GivenName', 'Surname', 'DisplayName', - 'Description', 'Department', 'Title', - 'EmailAddress', 'UserPrincipalName', 'Manager' - ) - - foreach ($attr in $supportedAttrs) { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $attr } | Should -Not -Throw - } - } - - It 'Rejects CreateIdentity-only attributes in EnsureAttribute' { - $createOnlyAttrs = @( - 'AccountPassword', 'AccountPasswordAsPlainText', - 'Path', 'Name', 'Enabled', 'OtherAttributes' - ) - - foreach ($attr in $createOnlyAttrs) { - { Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $attr } | - Should -Throw -ExpectedMessage '*Unsupported attribute*' - } - } - } -} From c1499b79daec6cd67717e1acf2be61e281203baf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:05:00 +0000 Subject: [PATCH 6/8] Address code review feedback - remove Ignored field and add Manager to test Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 | 1 - tests/Providers/ADIdentityProvider.Tests.ps1 | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index e1ee2cd1..468cee18 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -395,7 +395,6 @@ function New-IdleADIdentityProvider { IdentityKey = $IdentityKey Requested = $validationResult.Requested Applied = $validationResult.Supported - Ignored = $validationResult.Unsupported } $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesApplied', 'Attributes applied during identity creation', 'CreateIdentity', $eventData) } diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 38dcea8d..2f23c1c1 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -1576,7 +1576,7 @@ Describe 'AD identity provider' { } It 'EnsureAttribute succeeds with supported attributes' { - $supportedAttrs = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName') + $supportedAttrs = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName', 'Manager') foreach ($attr in $supportedAttrs) { { $script:ValidationTestProvider.EnsureAttribute('validationtest1', $attr, 'TestValue') } | Should -Not -Throw From 07092e2586f34953d224ccde0f6d47ec2938a4c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:05:45 +0000 Subject: [PATCH 7/8] Update AD joiner example to demonstrate OtherAttributes feature Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- examples/workflows/templates/ad-joiner-complete.psd1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/workflows/templates/ad-joiner-complete.psd1 b/examples/workflows/templates/ad-joiner-complete.psd1 index ceff16ec..e7862686 100644 --- a/examples/workflows/templates/ad-joiner-complete.psd1 +++ b/examples/workflows/templates/ad-joiner-complete.psd1 @@ -15,6 +15,12 @@ DisplayName = 'New User' Description = 'New employee account' Path = 'OU=Joiners,OU=Users,DC=contoso,DC=local' + OtherAttributes = @{ + # Custom LDAP attributes for organization-specific needs + employeeType = 'Employee' + extensionAttribute1 = 'EMPL-2024-001' + company = 'Contoso Ltd' + } } # Provider alias - references the key in the provider hashtable. # The host chooses this name when creating the provider hashtable. From 9466b4328ded984ebf22fd3ae0939581daf2ba7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:53:13 +0000 Subject: [PATCH 8/8] Address code review feedback: dynamic error messages, Manager resolution in EnsureAttribute, and documentation fixes Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 11 ++-- .../Private/Test-IdleADAttributeContract.ps1 | 31 +++++----- .../Public/New-IdleADIdentityProvider.ps1 | 11 +++- tests/Providers/ADIdentityProvider.Tests.ps1 | 57 +++++++++++++++++++ 4 files changed, 85 insertions(+), 25 deletions(-) diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index adfe0100..62ee523d 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -299,8 +299,8 @@ Only one password attribute can be used at a time. Using both `AccountPassword` #### Extension container - `OtherAttributes` (hashtable) - Custom LDAP attributes not covered by named parameters - Must be a hashtable - - Keys must be valid LDAP attribute names - - Values must be compatible with AD cmdlets (string, array of strings, etc.) + - Keys are interpreted as LDAP attribute names and are validated by the underlying AD cmdlets at runtime + - Values must use types supported by the AD cmdlets for `-OtherAttributes` (for example: string, string[], byte[]) **Example:** ```powershell @@ -377,12 +377,11 @@ To set custom LDAP attributes, use the 'OtherAttributes' container. ## Observability - **Events emitted by provider:** - - `Provider.AD.CreateIdentity.AttributesApplied` - Emitted after identity creation with attribute details + - `Provider.AD.CreateIdentity.AttributesRequested` - Emitted after identity creation with requested attributes - `Provider.AD.EnsureAttribute.AttributeChanged` - Emitted when an attribute is modified - **Event data includes:** - - Requested attributes - - Applied attributes - - Old and new values (for attribute changes) + - Requested attributes (for CreateIdentity) + - Old and new values (for attribute changes in EnsureAttribute) - **Sensitive data redaction:** Credential objects and secure strings are not included in operation results or events --- diff --git a/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 index 5d3ddb95..642b84e9 100644 --- a/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 @@ -65,15 +65,14 @@ function Test-IdleADAttributeContract { $errorMessage = "AD Provider: Unsupported attributes in CreateIdentity operation.`n" $errorMessage += "Unsupported attributes: $($unsupportedKeys -join ', ')`n`n" $errorMessage += "Supported attributes for CreateIdentity:`n" - $errorMessage += " - Identity: SamAccountName, UserPrincipalName, Path`n" - $errorMessage += " - Name: Name, GivenName, Surname, DisplayName`n" - $errorMessage += " - Organization: Description, Department, Title`n" - $errorMessage += " - Contact: EmailAddress`n" - $errorMessage += " - Relationship: Manager`n" - $errorMessage += " - Password: AccountPassword, AccountPasswordAsPlainText`n" - $errorMessage += " - State: Enabled`n" - $errorMessage += " - Extension: OtherAttributes (hashtable of LDAP attributes)`n`n" - $errorMessage += "To set custom LDAP attributes, use the 'OtherAttributes' container." + + # Generate supported attributes list from contract + $supportedAttributesList = ($supportedKeys | Sort-Object | ForEach-Object { " - $_" }) -join "`n" + $errorMessage += "$supportedAttributesList`n`n" + + if ('OtherAttributes' -in $supportedKeys) { + $errorMessage += "To set custom LDAP attributes, use the 'OtherAttributes' container." + } throw $errorMessage } @@ -103,13 +102,13 @@ function Test-IdleADAttributeContract { $errorMessage = "AD Provider: Unsupported attribute in EnsureAttribute operation.`n" $errorMessage += "Attribute: $AttributeName`n`n" $errorMessage += "Supported attributes for EnsureAttribute:`n" - $errorMessage += " - Name: GivenName, Surname, DisplayName`n" - $errorMessage += " - Organization: Description, Department, Title`n" - $errorMessage += " - Contact: EmailAddress`n" - $errorMessage += " - Identity: UserPrincipalName`n" - $errorMessage += " - Relationship: Manager`n`n" - $errorMessage += "Note: Custom LDAP attributes are not supported in EnsureAttribute.`n" - $errorMessage += "For custom attributes, use CreateIdentity with OtherAttributes or direct provider methods." + + # Generate supported attributes list from contract + $supportedAttributesList = ($supportedKeys | Sort-Object | ForEach-Object { " - $_" }) -join "`n" + $errorMessage += "$supportedAttributesList`n`n" + + $errorMessage += "Note: Custom LDAP attributes and password attributes are not supported in EnsureAttribute.`n" + $errorMessage += "For custom attributes, use CreateIdentity with OtherAttributes." throw $errorMessage } diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 468cee18..5cabcc34 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -394,9 +394,8 @@ function New-IdleADIdentityProvider { $eventData = @{ IdentityKey = $IdentityKey Requested = $validationResult.Requested - Applied = $validationResult.Supported } - $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesApplied', 'Attributes applied during identity creation', 'CreateIdentity', $eventData) + $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesRequested', 'Attributes requested during identity creation', 'CreateIdentity', $eventData) } return [pscustomobject]@{ @@ -490,7 +489,13 @@ function New-IdleADIdentityProvider { $changed = $false if ($currentValue -ne $Value) { - $adapter.SetUser($user.DistinguishedName, $Name, $Value) + # Special handling for Manager attribute - resolve to DN + $valueToSet = $Value + if ($Name -eq 'Manager' -and $null -ne $Value) { + $valueToSet = $adapter.ResolveManagerDN($Value) + } + + $adapter.SetUser($user.DistinguishedName, $Name, $valueToSet) $changed = $true # Emit observability event diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 2f23c1c1..b197fb1b 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -1480,6 +1480,63 @@ Describe 'AD identity provider' { $updatedUser.Manager | Should -Be $managerUser.DistinguishedName } + It 'EnsureAttribute resolves Manager from UPN' { + # Create a manager user + $managerAttrs = @{ + SamAccountName = 'lwilson' + UserPrincipalName = 'lwilson@contoso.com' + } + $managerUser = $script:ManagerTestAdapter.NewUser('lwilson@contoso.com', $managerAttrs, $true) + + # Create employee without manager + $attrs = @{ + SamAccountName = 'managertest15' + } + $user = $script:ManagerTestAdapter.NewUser('managertest15', $attrs, $true) + + # Set manager via UPN + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest15', + 'Manager', + 'lwilson@contoso.com', # UPN instead of DN + $null + ) + + $result.Changed | Should -Be $true + + # Verify manager was resolved to DN + $updatedUser = $script:ManagerTestAdapter.Store[$user.ObjectGuid.ToString()] + $updatedUser.Manager | Should -Be $managerUser.DistinguishedName + } + + It 'EnsureAttribute resolves Manager from GUID' { + # Create a manager user + $managerAttrs = @{ + SamAccountName = 'kthompson' + } + $managerUser = $script:ManagerTestAdapter.NewUser('kthompson', $managerAttrs, $true) + + # Create employee without manager + $attrs = @{ + SamAccountName = 'managertest16' + } + $user = $script:ManagerTestAdapter.NewUser('managertest16', $attrs, $true) + + # Set manager via GUID + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest16', + 'Manager', + $managerUser.ObjectGuid.ToString(), # GUID instead of DN + $null + ) + + $result.Changed | Should -Be $true + + # Verify manager was resolved to DN + $updatedUser = $script:ManagerTestAdapter.Store[$user.ObjectGuid.ToString()] + $updatedUser.Manager | Should -Be $managerUser.DistinguishedName + } + It 'CreateIdentity throws when Manager GUID does not exist' { # The fake adapter auto-creates users on sAMAccountName/UPN lookup for contract test compatibility # To test resolution failure, we use a non-existent GUID