From 1f4e9c57e9f9493a90781b56ca5bd0bf20f513ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:39:40 +0000 Subject: [PATCH 1/8] Initial plan From 72dc47506a59f981afdf5400f4060a2c7eb60288 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:52:01 +0000 Subject: [PATCH 2/8] Fix EnsureAttributes null handling; expand AD attribute contract; add LDAP field function Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 91 ++++++++++++ .../Private/Get-IdleADAttributeContract.ps1 | 134 ++++++++++++------ .../Private/Get-IdleADAttributeLDAPField.ps1 | 94 ++++++++++++ .../Private/New-IdleADAdapter.ps1 | 124 ++++++++++++++-- .../Private/Test-IdleADAttributeContract.ps1 | 36 +++-- .../Public/New-IdleADIdentityProvider.ps1 | 63 +++++--- .../Invoke-IdleStepEnsureAttributes.ps1 | 14 +- tests/Providers/ADIdentityProvider.Tests.ps1 | 64 +++++++-- 8 files changed, 523 insertions(+), 97 deletions(-) create mode 100644 src/IdLE.Provider.AD/Private/Get-IdleADAttributeLDAPField.ps1 diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 9147d105..f30126c4 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -115,6 +115,97 @@ $provider = New-IdleADIdentityProvider -AllowDelete - **Safety defaults:** deletion is disabled unless you pass `-AllowDelete` - **Entitlements:** groups only (`Kind='Group'`) +## Attribute handling + +### CreateIdentity attributes + +`IdLE.Step.CreateIdentity` maps attributes to `New-ADUser` named parameters. Attributes not listed in the named parameter set can be passed via the `OtherAttributes` container using their **LDAP attribute names** as keys. + +```powershell +@{ + Name = 'Create AD user' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' + Provider = 'AD' + Attributes = @{ + GivenName = '{{Request.GivenName}}' + Surname = '{{Request.Surname}}' + OtherAttributes = @{ + extensionAttribute1 = '{{Request.Department}}' + } + } + } +} +``` + +> **Note:** Keys in `OtherAttributes` must be valid **LDAP attribute names** (e.g. `extensionAttribute1`, `employeeType`), not PowerShell parameter names. + +### EnsureAttributes attributes + +`IdLE.Step.EnsureAttributes` maps attributes to `Set-ADUser` named parameters. Setting an attribute to `$null` clears the value from the directory. Attributes not listed in the named parameter set can be set or cleared via the `OtherAttributes` container using their **LDAP attribute names** as keys. + +**Named parameter attributes** (PowerShell parameter names used as keys): + +| Key | LDAP attribute | Description | +| --- | --- | --- | +| `GivenName` | `givenName` | First name | +| `Surname` | `sn` | Last name | +| `DisplayName` | `displayName` | Display name | +| `Initials` | `initials` | Initials | +| `SamAccountName` | `sAMAccountName` | Login name | +| `UserPrincipalName` | `userPrincipalName` | UPN | +| `Description` | `description` | Description | +| `Department` | `department` | Department | +| `Title` | `title` | Job title | +| `Company` | `company` | Company | +| `Division` | `division` | Division | +| `Office` | `physicalDeliveryOfficeName` | Office location | +| `EmployeeID` | `employeeID` | Employee ID | +| `EmployeeNumber` | `employeeNumber` | Employee number | +| `EmailAddress` | `mail` | Email address | +| `OfficePhone` | `telephoneNumber` | Office phone number | +| `MobilePhone` | `mobile` | Mobile phone number | +| `HomePhone` | `homePhone` | Home phone number | +| `Fax` | `facsimileTelephoneNumber` | Fax number | +| `StreetAddress` | `streetAddress` | Street address | +| `City` | `l` | City | +| `State` | `st` | State/province | +| `PostalCode` | `postalCode` | Postal code | +| `Country` | `co` | Country (full name) | +| `POBox` | `postOfficeBox` | P.O. box | +| `HomePage` | `wWWHomePage` | Web page | +| `Manager` | `manager` | Manager (DN, UPN, GUID, or sAMAccountName) | +| `HomeDirectory` | `homeDirectory` | Home directory path | +| `HomeDrive` | `homeDrive` | Home drive letter | +| `ProfilePath` | `profilePath` | Profile path | +| `ScriptPath` | `scriptPath` | Logon script path | + +Setting any of these to `$null` clears the attribute in AD using the `-Clear` parameter of `Set-ADUser`. + +**Custom LDAP attributes** (via OtherAttributes container): + +```powershell +@{ + Name = 'Clear phone numbers' + Type = 'IdLE.Step.EnsureAttributes' + With = @{ + IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' + Provider = 'AD' + Attributes = @{ + MobilePhone = $null # Clears the mobile attribute + OfficePhone = $null # Clears the telephoneNumber attribute + OtherAttributes = @{ + extensionAttribute1 = 'NewValue' # Sets custom LDAP attribute + employeeType = $null # Clears custom LDAP attribute + } + } + } +} +``` + +> **Note:** Keys in `OtherAttributes` must be valid **LDAP attribute names** (e.g. `mobile`, `telephoneNumber`, `extensionAttribute1`), not PowerShell parameter names. Setting a key to `$null` clears that LDAP attribute. + ## Examples These are the canonical, **doc-embed friendly** templates for AD. diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index 0a1753bd..31511fdd 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -4,11 +4,22 @@ function Get-IdleADAttributeContract { Returns the supported attribute contract for AD Provider operations. .DESCRIPTION - Defines which attributes are supported for CreateIdentity and EnsureAttribute operations. + Defines which attributes are supported for CreateIdentity and EnsureAttributes operations. This contract serves as the single source of truth for attribute validation. + Each entry includes: + - Target: 'Parameter' (Set-ADUser/New-ADUser named parameter) or 'Container' (special handling) + - Type: expected value type + - Required: whether the attribute is required + - LdapField: the verified LDAP schema attribute name (used for -Clear/-Replace in Set-ADUser) + + For CreateIdentity, attributes map to New-ADUser named parameters. + For EnsureAttributes, attributes map to Set-ADUser named parameters. + Custom LDAP attributes not listed here can be set via the OtherAttributes container + (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber'). + .PARAMETER Operation - The operation to get the contract for: 'CreateIdentity' or 'EnsureAttribute'. + The operation to get the contract for: 'CreateIdentity' or 'EnsureAttributes'. .OUTPUTS System.Collections.Hashtable @@ -17,72 +28,107 @@ function Get-IdleADAttributeContract { .EXAMPLE $contract = Get-IdleADAttributeContract -Operation 'CreateIdentity' $supportedKeys = $contract.Keys + + .EXAMPLE + $contract = Get-IdleADAttributeContract -Operation 'EnsureAttributes' + $contract['GivenName'].LdapField # Returns 'givenName' #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] - [ValidateSet('CreateIdentity', 'EnsureAttribute')] + [ValidateSet('CreateIdentity', 'EnsureAttributes')] [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 } - + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sAMAccountName' } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'userPrincipalName' } + Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = $null } + # 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 } - + Name = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'cn' } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'givenName' } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sn' } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'displayName' } + # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'description' } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'department' } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'title' } + # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'mail' } + # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'manager' } + # Password Attributes - AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } - AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } - ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } - AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } - + AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false; LdapField = $null } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = $null } + ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false; LdapField = $null } + AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false; LdapField = $null } + # State Attributes - Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } - - # Extension Container - OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false; LdapField = $null } + + # Extension Container (keys must be valid LDAP attribute names) + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false; LdapField = $null } } } - elseif ($Operation -eq 'EnsureAttribute') { + elseif ($Operation -eq 'EnsureAttributes') { return @{ # Name Attributes - GivenName = @{ Target = 'Parameter'; Type = 'String' } - Surname = @{ Target = 'Parameter'; Type = 'String' } - DisplayName = @{ Target = 'Parameter'; Type = 'String' } - + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'givenName' } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sn' } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'displayName' } + Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'initials' } + + # Identity Attributes + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sAMAccountName' } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'userPrincipalName' } + # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String' } - Department = @{ Target = 'Parameter'; Type = 'String' } - Title = @{ Target = 'Parameter'; Type = 'String' } - + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'description' } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'department' } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'title' } + Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'company' } + Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'division' } + Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'physicalDeliveryOfficeName' } + EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'employeeID' } + EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'employeeNumber' } + # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String' } - - # Identity Attributes - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String' } - + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'mail' } + OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'telephoneNumber' } + MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'mobile' } + HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'homePhone' } + Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'facsimileTelephoneNumber' } + + # Address Attributes + StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'streetAddress' } + City = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'l' } + State = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'st' } + PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'postalCode' } + Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'co' } + POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'postOfficeBox' } + + # Web / Profile Attributes + HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'wWWHomePage' } + # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String' } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'manager' } + + # Account / Profile Path Attributes + HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'homeDirectory' } + HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'homeDrive' } + ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'profilePath' } + ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'scriptPath' } + + # Extension Container (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber') + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false; LdapField = $null } } } } diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeLDAPField.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeLDAPField.ps1 new file mode 100644 index 00000000..fbeadc34 --- /dev/null +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeLDAPField.ps1 @@ -0,0 +1,94 @@ +function Get-IdleADAttributeLDAPField { + <# + .SYNOPSIS + Returns the verified LDAP attribute name for a given AD attribute key. + + .DESCRIPTION + Provides the authoritative mapping from friendly AD attribute names (as used in the + IdLE AD Provider contract) to their verified LDAP schema attribute names. + + LDAP names are verified against the Windows Server Active Directory LDAP schema. + This mapping is used for -Clear, -Replace, and -Add operations in Set-ADUser to + ensure correct attribute targeting in the directory. + + .PARAMETER AttributeName + The friendly attribute name (PowerShell parameter name or contract key) to look up. + + .OUTPUTS + System.String + The LDAP attribute name, or $null if the attribute is not a named parameter mapping. + + .EXAMPLE + Get-IdleADAttributeLDAPField -AttributeName 'GivenName' + # Returns: 'givenName' + + .EXAMPLE + Get-IdleADAttributeLDAPField -AttributeName 'EmailAddress' + # Returns: 'mail' + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $AttributeName + ) + + # Verified against Windows Server Active Directory LDAP schema documentation. + # Sources: RFC 4519, RFC 2798 (inetOrgPerson), MS-ADSC (Active Directory Schema Classes/Attributes). + $ldapFields = @{ + # Name Attributes + GivenName = 'givenName' # RFC 4519 section 2.12 + Surname = 'sn' # RFC 4519 section 2.32 + DisplayName = 'displayName' # MS-ADSC + Initials = 'initials' # RFC 2256 + + # Identity Attributes + SamAccountName = 'sAMAccountName' # MS-ADSC + UserPrincipalName = 'userPrincipalName' # MS-ADSC + + # Organizational Attributes + Description = 'description' # RFC 4519 section 2.5 + Department = 'department' # RFC 2798 section 2.2 + Title = 'title' # RFC 4519 section 2.38 + Company = 'company' # MS-ADSC + Division = 'division' # MS-ADSC + Office = 'physicalDeliveryOfficeName' # RFC 4519 section 2.24 + Organization = 'o' # RFC 4519 section 2.19 + EmployeeID = 'employeeID' # MS-ADSC + EmployeeNumber = 'employeeNumber' # RFC 2798 section 2.5 + + # Contact Attributes + EmailAddress = 'mail' # RFC 2798 section 2.13 + OfficePhone = 'telephoneNumber' # RFC 4519 section 2.35 + MobilePhone = 'mobile' # RFC 2798 section 2.15 + HomePhone = 'homePhone' # RFC 2798 section 2.11 + Fax = 'facsimileTelephoneNumber' # RFC 4519 section 2.10 + + # Address Attributes + StreetAddress = 'streetAddress' # RFC 4519 section 2.34 + City = 'l' # RFC 4519 section 2.16 (localityName) + State = 'st' # RFC 4519 section 2.33 (stateOrProvinceName) + PostalCode = 'postalCode' # RFC 4519 section 2.23 + Country = 'co' # RFC 2256 section 5.4 (full country name) + POBox = 'postOfficeBox' # RFC 4519 section 2.25 + + # Web / Profile Attributes + HomePage = 'wWWHomePage' # MS-ADSC + + # Relationship Attributes + Manager = 'manager' # RFC 4524 section 2.1 + + # Account/Profile Path Attributes + HomeDirectory = 'homeDirectory' # MS-ADSC + HomeDrive = 'homeDrive' # MS-ADSC + ProfilePath = 'profilePath' # MS-ADSC + ScriptPath = 'scriptPath' # MS-ADSC + } + + if ($ldapFields.ContainsKey($AttributeName)) { + return $ldapFields[$AttributeName] + } + + return $null +} diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index e82c8cfb..a8d98cf7 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -191,7 +191,7 @@ function New-IdleADAdapter { $escapedUpn = $escapedUpn -replace '''', '''''' $params = @{ Filter = "UserPrincipalName -eq '$escapedUpn'" - Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') + Properties = '*' ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -220,7 +220,7 @@ function New-IdleADAdapter { $params = @{ Filter = "sAMAccountName -eq '$escapedSam'" - Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') + Properties = '*' ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -245,7 +245,7 @@ function New-IdleADAdapter { $params = @{ Identity = $Guid - Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') + Properties = '*' ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -517,7 +517,11 @@ function New-IdleADAdapter { [Parameter()] [AllowNull()] - [object] $Value + [object] $Value, + + [Parameter()] + [AllowNull()] + [object] $CurrentValue ) $params = @{ @@ -529,15 +533,100 @@ function New-IdleADAdapter { $params['Credential'] = $this.Credential } + # Resolve the LDAP field name for -Clear operations on named parameters + $ldapField = Get-IdleADAttributeLDAPField -AttributeName $AttributeName + switch ($AttributeName) { - 'GivenName' { $params['GivenName'] = $Value } - 'Surname' { $params['Surname'] = $Value } - 'DisplayName' { $params['DisplayName'] = $Value } - 'Description' { $params['Description'] = $Value } - 'Department' { $params['Department'] = $Value } - 'Title' { $params['Title'] = $Value } - 'EmailAddress' { $params['EmailAddress'] = $Value } - 'UserPrincipalName' { $params['UserPrincipalName'] = $Value } + 'GivenName' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['GivenName'] = $Value } + } + 'Surname' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Surname'] = $Value } + } + 'DisplayName' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['DisplayName'] = $Value } + } + 'Description' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Description'] = $Value } + } + 'Department' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Department'] = $Value } + } + 'Title' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Title'] = $Value } + } + 'EmailAddress' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['EmailAddress'] = $Value } + } + 'UserPrincipalName' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['UserPrincipalName'] = $Value } + } + 'SamAccountName' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['SamAccountName'] = $Value } + } + 'Initials' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Initials'] = $Value } + } + 'Company' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Company'] = $Value } + } + 'Division' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Division'] = $Value } + } + 'Office' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Office'] = $Value } + } + 'EmployeeID' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['EmployeeID'] = $Value } + } + 'EmployeeNumber' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['EmployeeNumber'] = $Value } + } + 'OfficePhone' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['OfficePhone'] = $Value } + } + 'MobilePhone' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['MobilePhone'] = $Value } + } + 'HomePhone' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomePhone'] = $Value } + } + 'Fax' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Fax'] = $Value } + } + 'StreetAddress' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['StreetAddress'] = $Value } + } + 'City' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['City'] = $Value } + } + 'State' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['State'] = $Value } + } + 'PostalCode' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['PostalCode'] = $Value } + } + 'Country' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Country'] = $Value } + } + 'POBox' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['POBox'] = $Value } + } + 'HomePage' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomePage'] = $Value } + } + 'HomeDirectory' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomeDirectory'] = $Value } + } + 'HomeDrive' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomeDrive'] = $Value } + } + 'ProfilePath' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['ProfilePath'] = $Value } + } + 'ScriptPath' { + if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['ScriptPath'] = $Value } + } 'Manager' { # Expect $Value to be a normalized DN or $null. if ($null -eq $Value) { @@ -547,7 +636,16 @@ function New-IdleADAdapter { } } default { - $params['Replace'] = @{ $AttributeName = $Value } + # Custom LDAP attribute: use -Clear for null, -Add when no current value, -Replace when updating + if ($null -eq $Value) { + $params['Clear'] = $AttributeName + } + elseif ($null -eq $CurrentValue) { + $params['Add'] = @{ $AttributeName = $Value } + } + else { + $params['Replace'] = @{ $AttributeName = $Value } + } } } diff --git a/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 index 642b84e9..9ae8eb5f 100644 --- a/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Test-IdleADAttributeContract.ps1 @@ -11,10 +11,10 @@ function Test-IdleADAttributeContract { Hashtable of attributes to validate. .PARAMETER Operation - The operation context: 'CreateIdentity' or 'EnsureAttribute'. + The operation context: 'CreateIdentity' or 'EnsureAttributes'. .PARAMETER AttributeName - For EnsureAttribute, the specific attribute name being set. + For EnsureAttributes, the specific attribute name being set. .OUTPUTS System.Collections.Hashtable @@ -28,8 +28,8 @@ function Test-IdleADAttributeContract { # Throws if unsupported attributes found .EXAMPLE - $result = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName 'InvalidAttr' - # Throws if attribute not supported for EnsureAttribute + $result = Test-IdleADAttributeContract -Operation 'EnsureAttributes' -AttributeName 'MobilePhone' + # Throws if attribute not supported for EnsureAttributes #> [CmdletBinding()] [OutputType([hashtable])] @@ -39,7 +39,7 @@ function Test-IdleADAttributeContract { [hashtable] $Attributes, [Parameter(Mandatory)] - [ValidateSet('CreateIdentity', 'EnsureAttribute')] + [ValidateSet('CreateIdentity', 'EnsureAttributes')] [string] $Operation, [Parameter()] @@ -91,24 +91,34 @@ function Test-IdleADAttributeContract { Unsupported = $unsupportedKeys } } - elseif ($Operation -eq 'EnsureAttribute') { + elseif ($Operation -eq 'EnsureAttributes') { if ([string]::IsNullOrWhiteSpace($AttributeName)) { - throw "AD Provider: AttributeName is required for EnsureAttribute validation." + throw "AD Provider: AttributeName is required for EnsureAttributes validation." + } + + # OtherAttributes is a valid container key in EnsureAttributes + if ($AttributeName -eq 'OtherAttributes') { + return @{ + Requested = @($AttributeName) + Supported = @($AttributeName) + Unsupported = @() + } } $supportedKeys = @($contract.Keys) if ($AttributeName -notin $supportedKeys) { - $errorMessage = "AD Provider: Unsupported attribute in EnsureAttribute operation.`n" + $errorMessage = "AD Provider: Unsupported attribute in EnsureAttributes operation.`n" $errorMessage += "Attribute: $AttributeName`n`n" - $errorMessage += "Supported attributes for EnsureAttribute:`n" + $errorMessage += "Supported attributes for EnsureAttributes:`n" - # Generate supported attributes list from contract - $supportedAttributesList = ($supportedKeys | Sort-Object | ForEach-Object { " - $_" }) -join "`n" + # Generate supported attributes list from contract (exclude OtherAttributes container) + $namedKeys = @($supportedKeys | Where-Object { $_ -ne 'OtherAttributes' }) + $supportedAttributesList = ($namedKeys | 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." + $errorMessage += "Note: For custom LDAP attributes not listed above, use the 'OtherAttributes' container`n" + $errorMessage += "with valid LDAP attribute names as keys (e.g. OtherAttributes = @{ mobile = `$null })." throw $errorMessage } diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 56636d7a..5a001910 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -565,7 +565,7 @@ function New-IdleADIdentityProvider { ) # Validate attribute against contract (strict mode - will throw on unsupported attributes) - $validationResult = Test-IdleADAttributeContract -Operation 'EnsureAttribute' -AttributeName $Name + $validationResult = Test-IdleADAttributeContract -Operation 'EnsureAttributes' -AttributeName $Name $adapter = $this.GetEffectiveAdapter($AuthSession) @@ -577,25 +577,54 @@ function New-IdleADIdentityProvider { } $changed = $false - if ($currentValue -ne $Value) { - # Special handling for Manager attribute - resolve to DN - $valueToSet = $Value - if ($Name -eq 'Manager' -and $null -ne $Value) { - $valueToSet = $adapter.ResolveManagerDN($Value) + + # Handle OtherAttributes container: apply each sub-attribute individually + if ($Name -eq 'OtherAttributes') { + if ($null -ne $Value -and $Value -is [hashtable]) { + foreach ($ldapAttr in $Value.Keys) { + $ldapValue = $Value[$ldapAttr] + $currentLdapValue = $null + if ($user.PSObject.Properties.Name -contains $ldapAttr) { + $currentLdapValue = $user.$ldapAttr + } + $attrChanged = ($null -ne $ldapValue -or $null -ne $currentLdapValue) -and ($currentLdapValue -ne $ldapValue) + if ($attrChanged) { + $adapter.SetUser($user.DistinguishedName, $ldapAttr, $ldapValue, $currentLdapValue) + $changed = $true + } + } } - - $adapter.SetUser($user.DistinguishedName, $Name, $valueToSet) - $changed = $true + } + else { + # Use reference equality check that treats $null correctly + $needsChange = if ($null -eq $currentValue -and $null -eq $Value) { + $false + } elseif ($null -eq $currentValue -or $null -eq $Value) { + $true + } else { + $currentValue -ne $Value + } + + if ($needsChange) { + # 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, $currentValue) + $changed = $true - # Emit observability event - if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) { - $eventData = @{ - IdentityKey = $IdentityKey - AttributeName = $Name - OldValue = $currentValue - NewValue = $Value + # Emit observability event + if ($this.PSObject.Properties.Name -contains 'EventSink' -and $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) } - $this.EventSink.WriteEvent('Provider.AD.EnsureAttribute.AttributeChanged', "Attribute '$Name' changed", 'EnsureAttribute', $eventData) } } diff --git a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 index d89bf137..30d50f61 100644 --- a/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 +++ b/src/IdLE.Steps.Common/Public/Invoke-IdleStepEnsureAttributes.ps1 @@ -122,12 +122,24 @@ function Invoke-IdleStepEnsureAttributes { $attrValue = $attributes[$key] try { + # Explicitly construct the arguments array to preserve $null values. + # Using @() with a $null element can drop the null in some PowerShell contexts; + # a typed object array guarantees the null is included as a positional argument. + $callArgs = [object[]]::new(2) + $callArgs[0] = [string]$with.IdentityKey + $callArgs[1] = $attrName + # Append value as third element preserving $null + $callArgsWithValue = [object[]]::new(3) + $callArgsWithValue[0] = $callArgs[0] + $callArgsWithValue[1] = $callArgs[1] + $callArgsWithValue[2] = $attrValue + $result = Invoke-IdleProviderMethod ` -Context $Context ` -With $with ` -ProviderAlias $providerAlias ` -MethodName 'EnsureAttribute' ` - -MethodArguments @([string]$with.IdentityKey, $attrName, $attrValue) + -MethodArguments $callArgsWithValue $changed = $false if ($null -ne $result -and ($result.PSObject.Properties.Name -contains 'Changed')) { diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 46e852e8..9f1dd796 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -349,7 +349,7 @@ Describe 'AD identity provider' { } -Force $adapter | Add-Member -MemberType ScriptMethod -Name SetUser -Value { - param([string]$Identity, [string]$AttributeName, $Value) + param([string]$Identity, [string]$AttributeName, $Value, $CurrentValue) $user = $null foreach ($key in $this.Store.Keys) { @@ -374,10 +374,17 @@ Describe 'AD identity provider' { return } - # Handle known properties - $knownProps = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName') - if ($AttributeName -in $knownProps -and $null -ne $user.PSObject.Properties[$AttributeName]) { - $user.$AttributeName = $Value + # Handle known properties (set or clear) + $knownProps = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName', + 'SamAccountName', 'Initials', 'Company', 'Division', 'Office', 'EmployeeID', 'EmployeeNumber', + 'OfficePhone', 'MobilePhone', 'HomePhone', 'Fax', 'StreetAddress', 'City', 'State', + 'PostalCode', 'Country', 'POBox', 'HomePage', 'HomeDirectory', 'HomeDrive', 'ProfilePath', 'ScriptPath') + if ($AttributeName -in $knownProps) { + if ($null -ne $user.PSObject.Properties[$AttributeName]) { + $user.$AttributeName = $Value + } else { + $user | Add-Member -MemberType NoteProperty -Name $AttributeName -Value $Value -Force + } } else { # Add as a dynamic property if it doesn't exist if ($null -eq $user.PSObject.Properties[$AttributeName]) { @@ -1672,7 +1679,14 @@ Describe 'AD identity provider' { } It 'EnsureAttribute succeeds with supported attributes' { - $supportedAttrs = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName', 'Manager') + $supportedAttrs = @( + 'GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', + 'EmailAddress', 'UserPrincipalName', 'Manager', + 'SamAccountName', 'Initials', 'Company', 'Division', 'Office', + 'EmployeeID', 'EmployeeNumber', 'OfficePhone', 'MobilePhone', 'HomePhone', 'Fax', + 'StreetAddress', 'City', 'State', 'PostalCode', 'Country', 'POBox', 'HomePage', + 'HomeDirectory', 'HomeDrive', 'ProfilePath', 'ScriptPath' + ) foreach ($attr in $supportedAttrs) { { $script:ValidationTestProvider.EnsureAttribute('validationtest1', $attr, 'TestValue') } | Should -Not -Throw @@ -1688,9 +1702,41 @@ Describe 'AD identity provider' { } } - It 'EnsureAttribute rejects OtherAttributes' { - { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', @{}) } | - Should -Throw -ExpectedMessage '*Unsupported attribute*' + It 'EnsureAttribute accepts OtherAttributes container with LDAP attribute hashtable' { + # OtherAttributes is now a supported container for custom LDAP attributes + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', @{ mobile = 'test' }) } | + Should -Not -Throw + } + + It 'EnsureAttribute with OtherAttributes sets $null value to clear LDAP attribute' { + # Pre-set the custom attribute on the user + $testUser = $script:ValidationTestAdapter.GetUserBySam('validationtest1') + $testUser | Add-Member -MemberType NoteProperty -Name 'mobile' -Value '+1234567890' -Force + + $result = $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', @{ mobile = $null }) + + $result.Changed | Should -BeTrue + } + + It 'EnsureAttribute with $null value clears a named attribute' { + # Pre-set an attribute on the user + $testUser = $script:ValidationTestAdapter.GetUserBySam('validationtest1') + $testUser.GivenName = 'OldFirst' + + $result = $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'GivenName', $null) + + $result.Changed | Should -BeTrue + $result.Value | Should -BeNullOrEmpty + } + + It 'EnsureAttribute with $null is idempotent when attribute is already null' { + # Pre-set the attribute to null + $testUser = $script:ValidationTestAdapter.GetUserBySam('validationtest1') + $testUser.GivenName = $null + + $result = $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'GivenName', $null) + + $result.Changed | Should -BeFalse } } From 350e950e6d31602b3eeaf098987ad8db2880a99e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 20:53:20 +0000 Subject: [PATCH 3/8] Address code review: simplify null check; use -Replace instead of -Add for custom LDAP attrs Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 | 6 ++---- src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index a8d98cf7..2122c1dd 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -636,13 +636,11 @@ function New-IdleADAdapter { } } default { - # Custom LDAP attribute: use -Clear for null, -Add when no current value, -Replace when updating + # Custom LDAP attribute: use -Clear for null, -Replace for all non-null values. + # -Replace works for both new (attribute not yet set) and existing values in AD. if ($null -eq $Value) { $params['Clear'] = $AttributeName } - elseif ($null -eq $CurrentValue) { - $params['Add'] = @{ $AttributeName = $Value } - } else { $params['Replace'] = @{ $AttributeName = $Value } } diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 5a001910..401b0c84 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -587,7 +587,7 @@ function New-IdleADIdentityProvider { if ($user.PSObject.Properties.Name -contains $ldapAttr) { $currentLdapValue = $user.$ldapAttr } - $attrChanged = ($null -ne $ldapValue -or $null -ne $currentLdapValue) -and ($currentLdapValue -ne $ldapValue) + $attrChanged = $currentLdapValue -ne $ldapValue if ($attrChanged) { $adapter.SetUser($user.DistinguishedName, $ldapAttr, $ldapValue, $currentLdapValue) $changed = $true From 89e505c4034d1a57c059145b8c0528a1f77aa889 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:19:32 +0000 Subject: [PATCH 4/8] Generalize SetUser using contract+LDAP func; remove LDAP hardcoding from contract; remove docs table Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/providers/provider-ad.md | 38 ------ .../Private/Get-IdleADAttributeContract.ps1 | 120 +++++++++-------- .../Private/New-IdleADAdapter.ps1 | 126 +++--------------- 3 files changed, 85 insertions(+), 199 deletions(-) diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index f30126c4..7e42da22 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -145,44 +145,6 @@ $provider = New-IdleADIdentityProvider -AllowDelete `IdLE.Step.EnsureAttributes` maps attributes to `Set-ADUser` named parameters. Setting an attribute to `$null` clears the value from the directory. Attributes not listed in the named parameter set can be set or cleared via the `OtherAttributes` container using their **LDAP attribute names** as keys. -**Named parameter attributes** (PowerShell parameter names used as keys): - -| Key | LDAP attribute | Description | -| --- | --- | --- | -| `GivenName` | `givenName` | First name | -| `Surname` | `sn` | Last name | -| `DisplayName` | `displayName` | Display name | -| `Initials` | `initials` | Initials | -| `SamAccountName` | `sAMAccountName` | Login name | -| `UserPrincipalName` | `userPrincipalName` | UPN | -| `Description` | `description` | Description | -| `Department` | `department` | Department | -| `Title` | `title` | Job title | -| `Company` | `company` | Company | -| `Division` | `division` | Division | -| `Office` | `physicalDeliveryOfficeName` | Office location | -| `EmployeeID` | `employeeID` | Employee ID | -| `EmployeeNumber` | `employeeNumber` | Employee number | -| `EmailAddress` | `mail` | Email address | -| `OfficePhone` | `telephoneNumber` | Office phone number | -| `MobilePhone` | `mobile` | Mobile phone number | -| `HomePhone` | `homePhone` | Home phone number | -| `Fax` | `facsimileTelephoneNumber` | Fax number | -| `StreetAddress` | `streetAddress` | Street address | -| `City` | `l` | City | -| `State` | `st` | State/province | -| `PostalCode` | `postalCode` | Postal code | -| `Country` | `co` | Country (full name) | -| `POBox` | `postOfficeBox` | P.O. box | -| `HomePage` | `wWWHomePage` | Web page | -| `Manager` | `manager` | Manager (DN, UPN, GUID, or sAMAccountName) | -| `HomeDirectory` | `homeDirectory` | Home directory path | -| `HomeDrive` | `homeDrive` | Home drive letter | -| `ProfilePath` | `profilePath` | Profile path | -| `ScriptPath` | `scriptPath` | Logon script path | - -Setting any of these to `$null` clears the attribute in AD using the `-Clear` parameter of `Set-ADUser`. - **Custom LDAP attributes** (via OtherAttributes container): ```powershell diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index 31511fdd..a9626e70 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -11,7 +11,7 @@ function Get-IdleADAttributeContract { - Target: 'Parameter' (Set-ADUser/New-ADUser named parameter) or 'Container' (special handling) - Type: expected value type - Required: whether the attribute is required - - LdapField: the verified LDAP schema attribute name (used for -Clear/-Replace in Set-ADUser) + - LdapField: the verified LDAP schema attribute name, resolved via Get-IdleADAttributeLDAPField For CreateIdentity, attributes map to New-ADUser named parameters. For EnsureAttributes, attributes map to Set-ADUser named parameters. @@ -31,7 +31,7 @@ function Get-IdleADAttributeContract { .EXAMPLE $contract = Get-IdleADAttributeContract -Operation 'EnsureAttributes' - $contract['GivenName'].LdapField # Returns 'givenName' + $contract['GivenName'].LdapField # Returns 'givenName' via Get-IdleADAttributeLDAPField #> [CmdletBinding()] [OutputType([hashtable])] @@ -42,93 +42,105 @@ function Get-IdleADAttributeContract { ) if ($Operation -eq 'CreateIdentity') { - return @{ + $contract = @{ # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sAMAccountName' } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'userPrincipalName' } - Path = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = $null } + 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; LdapField = 'cn' } - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'givenName' } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sn' } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'displayName' } + 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; LdapField = 'description' } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'department' } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'title' } + 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; LdapField = 'mail' } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'manager' } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Password Attributes - AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false; LdapField = $null } - AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = $null } - ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false; LdapField = $null } - AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false; LdapField = $null } + AccountPassword = @{ Target = 'Parameter'; Type = 'SecureString|String'; Required = $false } + AccountPasswordAsPlainText = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ResetOnFirstLogin = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } + AllowPlainTextPasswordOutput = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # State Attributes - Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false; LdapField = $null } + Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } # Extension Container (keys must be valid LDAP attribute names) - OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false; LdapField = $null } + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } } } elseif ($Operation -eq 'EnsureAttributes') { - return @{ + $contract = @{ # Name Attributes - GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'givenName' } - Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sn' } - DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'displayName' } - Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'initials' } + GivenName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Surname = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + DisplayName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Initials = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Identity Attributes - SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'sAMAccountName' } - UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'userPrincipalName' } + SamAccountName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + UserPrincipalName = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Organizational Attributes - Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'description' } - Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'department' } - Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'title' } - Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'company' } - Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'division' } - Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'physicalDeliveryOfficeName' } - EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'employeeID' } - EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'employeeNumber' } + Description = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Department = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Title = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Company = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Division = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Office = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeID = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + EmployeeNumber = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Contact Attributes - EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'mail' } - OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'telephoneNumber' } - MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'mobile' } - HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'homePhone' } - Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'facsimileTelephoneNumber' } + EmailAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + OfficePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + MobilePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomePhone = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Fax = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Address Attributes - StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'streetAddress' } - City = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'l' } - State = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'st' } - PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'postalCode' } - Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'co' } - POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'postOfficeBox' } + StreetAddress = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + City = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + State = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + PostalCode = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + Country = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + POBox = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Web / Profile Attributes - HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'wWWHomePage' } + HomePage = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Relationship Attributes - Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'manager' } + Manager = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Account / Profile Path Attributes - HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'homeDirectory' } - HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'homeDrive' } - ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'profilePath' } - ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false; LdapField = 'scriptPath' } + HomeDirectory = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + HomeDrive = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ProfilePath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } + ScriptPath = @{ Target = 'Parameter'; Type = 'String'; Required = $false } # Extension Container (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber') - OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false; LdapField = $null } + OtherAttributes = @{ Target = 'Container'; Type = 'Hashtable'; Required = $false } } } + + # Enrich each Parameter entry with its LDAP field name from the dedicated mapping function + foreach ($key in @($contract.Keys)) { + if ($contract[$key].Target -eq 'Parameter') { + $contract[$key]['LdapField'] = Get-IdleADAttributeLDAPField -AttributeName $key + } else { + $contract[$key]['LdapField'] = $null + } + } + + return $contract } + diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 2122c1dd..3f1d44ec 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -533,117 +533,29 @@ function New-IdleADAdapter { $params['Credential'] = $this.Credential } - # Resolve the LDAP field name for -Clear operations on named parameters - $ldapField = Get-IdleADAttributeLDAPField -AttributeName $AttributeName + # Use the EnsureAttributes contract to determine if this is a named Set-ADUser parameter. + # Named parameters are set/cleared directly; custom LDAP attributes use -Replace/-Clear. + $ensureContract = Get-IdleADAttributeContract -Operation 'EnsureAttributes' - switch ($AttributeName) { - 'GivenName' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['GivenName'] = $Value } + if ($ensureContract.ContainsKey($AttributeName) -and $ensureContract[$AttributeName].Target -eq 'Parameter') { + # Named Set-ADUser parameter: clear via LDAP field name or set via parameter name + $ldapField = $ensureContract[$AttributeName].LdapField + if ($null -eq $Value) { + # Fallback to attribute name if no LDAP mapping exists (safety guard) + $params['Clear'] = if ($null -ne $ldapField) { $ldapField } else { $AttributeName } } - 'Surname' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Surname'] = $Value } - } - 'DisplayName' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['DisplayName'] = $Value } - } - 'Description' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Description'] = $Value } - } - 'Department' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Department'] = $Value } - } - 'Title' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Title'] = $Value } - } - 'EmailAddress' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['EmailAddress'] = $Value } - } - 'UserPrincipalName' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['UserPrincipalName'] = $Value } - } - 'SamAccountName' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['SamAccountName'] = $Value } - } - 'Initials' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Initials'] = $Value } - } - 'Company' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Company'] = $Value } - } - 'Division' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Division'] = $Value } - } - 'Office' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Office'] = $Value } - } - 'EmployeeID' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['EmployeeID'] = $Value } - } - 'EmployeeNumber' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['EmployeeNumber'] = $Value } - } - 'OfficePhone' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['OfficePhone'] = $Value } - } - 'MobilePhone' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['MobilePhone'] = $Value } - } - 'HomePhone' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomePhone'] = $Value } - } - 'Fax' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Fax'] = $Value } - } - 'StreetAddress' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['StreetAddress'] = $Value } - } - 'City' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['City'] = $Value } - } - 'State' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['State'] = $Value } - } - 'PostalCode' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['PostalCode'] = $Value } - } - 'Country' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['Country'] = $Value } - } - 'POBox' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['POBox'] = $Value } - } - 'HomePage' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomePage'] = $Value } - } - 'HomeDirectory' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomeDirectory'] = $Value } - } - 'HomeDrive' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['HomeDrive'] = $Value } - } - 'ProfilePath' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['ProfilePath'] = $Value } - } - 'ScriptPath' { - if ($null -eq $Value) { $params['Clear'] = $ldapField } else { $params['ScriptPath'] = $Value } + else { + $params[$AttributeName] = $Value } - 'Manager' { - # Expect $Value to be a normalized DN or $null. - if ($null -eq $Value) { - $params['Clear'] = 'manager' - } else { - $params['Manager'] = $Value - } + } + else { + # Custom LDAP attribute: use -Clear for null, -Replace for non-null. + # -Replace works regardless of whether $CurrentValue is set or not. + if ($null -eq $Value) { + $params['Clear'] = $AttributeName } - default { - # Custom LDAP attribute: use -Clear for null, -Replace for all non-null values. - # -Replace works for both new (attribute not yet set) and existing values in AD. - if ($null -eq $Value) { - $params['Clear'] = $AttributeName - } - else { - $params['Replace'] = @{ $AttributeName = $Value } - } + else { + $params['Replace'] = @{ $AttributeName = $Value } } } From 8652d3079156a98e54fb1b2f8b768f452ce36913 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:25:02 +0000 Subject: [PATCH 5/8] SetUser: fetch ldapField directly from Get-IdleADAttributeLDAPField, not from contract entry Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Private/Get-IdleADAttributeContract.ps1 | 15 ++++----------- .../Private/New-IdleADAdapter.ps1 | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index a9626e70..1e8cb77d 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -11,13 +11,15 @@ function Get-IdleADAttributeContract { - Target: 'Parameter' (Set-ADUser/New-ADUser named parameter) or 'Container' (special handling) - Type: expected value type - Required: whether the attribute is required - - LdapField: the verified LDAP schema attribute name, resolved via Get-IdleADAttributeLDAPField For CreateIdentity, attributes map to New-ADUser named parameters. For EnsureAttributes, attributes map to Set-ADUser named parameters. Custom LDAP attributes not listed here can be set via the OtherAttributes container (keys must be valid LDAP attribute names, e.g. 'mobile', 'telephoneNumber'). + To resolve the underlying LDAP attribute name for a contract key, use + Get-IdleADAttributeLDAPField. + .PARAMETER Operation The operation to get the contract for: 'CreateIdentity' or 'EnsureAttributes'. @@ -31,7 +33,7 @@ function Get-IdleADAttributeContract { .EXAMPLE $contract = Get-IdleADAttributeContract -Operation 'EnsureAttributes' - $contract['GivenName'].LdapField # Returns 'givenName' via Get-IdleADAttributeLDAPField + $supportedKeys = $contract.Keys #> [CmdletBinding()] [OutputType([hashtable])] @@ -132,15 +134,6 @@ function Get-IdleADAttributeContract { } } - # Enrich each Parameter entry with its LDAP field name from the dedicated mapping function - foreach ($key in @($contract.Keys)) { - if ($contract[$key].Target -eq 'Parameter') { - $contract[$key]['LdapField'] = Get-IdleADAttributeLDAPField -AttributeName $key - } else { - $contract[$key]['LdapField'] = $null - } - } - return $contract } diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index 3f1d44ec..28b77786 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -539,7 +539,7 @@ function New-IdleADAdapter { if ($ensureContract.ContainsKey($AttributeName) -and $ensureContract[$AttributeName].Target -eq 'Parameter') { # Named Set-ADUser parameter: clear via LDAP field name or set via parameter name - $ldapField = $ensureContract[$AttributeName].LdapField + $ldapField = Get-IdleADAttributeLDAPField -AttributeName $AttributeName if ($null -eq $Value) { # Fallback to attribute name if no LDAP mapping exists (safety guard) $params['Clear'] = if ($null -ne $ldapField) { $ldapField } else { $AttributeName } From a10be480f2579345c80721f813d4031ae398184d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:38:53 +0100 Subject: [PATCH 6/8] Update tests/Providers/ADIdentityProvider.Tests.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/Providers/ADIdentityProvider.Tests.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 9f1dd796..ea28660b 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -1718,6 +1718,15 @@ Describe 'AD identity provider' { $result.Changed | Should -BeTrue } + It 'EnsureAttribute with OtherAttributes is idempotent when LDAP attribute is already null' { + # Pre-set the custom LDAP attribute to null + $testUser = $script:ValidationTestAdapter.GetUserBySam('validationtest1') + $testUser | Add-Member -MemberType NoteProperty -Name 'mobile' -Value $null -Force + + $result = $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', @{ mobile = $null }) + + $result.Changed | Should -BeFalse + } It 'EnsureAttribute with $null value clears a named attribute' { # Pre-set an attribute on the user $testUser = $script:ValidationTestAdapter.GetUserBySam('validationtest1') From 94d59431234c172153cff8d0a6c35c5bfa2da4e0 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:49:38 +0100 Subject: [PATCH 7/8] Update src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Public/New-IdleADIdentityProvider.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 401b0c84..a1ef46c5 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -587,7 +587,13 @@ function New-IdleADIdentityProvider { if ($user.PSObject.Properties.Name -contains $ldapAttr) { $currentLdapValue = $user.$ldapAttr } - $attrChanged = $currentLdapValue -ne $ldapValue + $attrChanged = if ($null -eq $currentLdapValue -and $null -eq $ldapValue) { + $false + } elseif ($null -eq $currentLdapValue -or $null -eq $ldapValue) { + $true + } else { + $currentLdapValue -ne $ldapValue + } if ($attrChanged) { $adapter.SetUser($user.DistinguishedName, $ldapAttr, $ldapValue, $currentLdapValue) $changed = $true From 94881805902cefc51e238358155997a1e03448d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:00:07 +0000 Subject: [PATCH 8/8] Validate OtherAttributes is a hashtable in EnsureAttribute; add regression test Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 | 3 +++ tests/Providers/ADIdentityProvider.Tests.ps1 | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index a1ef46c5..af595630 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -580,6 +580,9 @@ function New-IdleADIdentityProvider { # Handle OtherAttributes container: apply each sub-attribute individually if ($Name -eq 'OtherAttributes') { + if ($null -ne $Value -and $Value -isnot [hashtable]) { + throw "AD Provider: 'OtherAttributes' must be a hashtable. Received type: $($Value.GetType().FullName)" + } if ($null -ne $Value -and $Value -is [hashtable]) { foreach ($ldapAttr in $Value.Keys) { $ldapValue = $Value[$ldapAttr] diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index ea28660b..81842bfa 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -1708,6 +1708,11 @@ Describe 'AD identity provider' { Should -Not -Throw } + It 'EnsureAttribute throws when OtherAttributes value is not a hashtable' { + { $script:ValidationTestProvider.EnsureAttribute('validationtest1', 'OtherAttributes', 'mobile=123') } | + Should -Throw -ExpectedMessage "*'OtherAttributes' must be a hashtable*" + } + It 'EnsureAttribute with OtherAttributes sets $null value to clear LDAP attribute' { # Pre-set the custom attribute on the user $testUser = $script:ValidationTestAdapter.GetUserBySam('validationtest1')