diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 27424def..0338bba3 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -604,6 +604,100 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers } ``` +### Manager Attribute Handling + +The AD provider supports the `Manager` attribute for both `CreateIdentity` and `EnsureAttribute` operations with automatic DN resolution. + +**Supported Input Formats:** + +The Manager value can be specified in multiple formats, which will be automatically resolved to a Distinguished Name (DN): + +- **Distinguished Name (DN)**: Direct DN string (no resolution needed) + - Example: `CN=Jane Smith,OU=Managers,DC=contoso,DC=local` +- **GUID**: User's ObjectGuid (resolved to DN) + - Example: `a1b2c3d4-e5f6-7890-abcd-ef1234567890` +- **UPN**: UserPrincipalName (resolved to DN) + - Example: `jsmith@contoso.local` +- **sAMAccountName**: Simple username (resolved to DN) + - Example: `jsmith` + +The provider automatically detects the format and resolves it to the manager's DN. If the manager cannot be found, an error is thrown with a clear message. + +**CreateIdentity with Manager (DN):** + +```powershell +@{ + Name = 'CreateUserWithManager' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + Provider = 'Identity' + IdentityKey = 'jdoe' + Attributes = @{ + GivenName = 'John' + Surname = 'Doe' + UserPrincipalName = 'jdoe@contoso.local' + Manager = 'CN=Jane Smith,OU=Managers,DC=contoso,DC=local' # DN + } + AuthSessionName = 'ActiveDirectory' + } +} +``` + +**CreateIdentity with Manager (sAMAccountName):** + +```powershell +@{ + Name = 'CreateUserWithManagerSam' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + Provider = 'Identity' + IdentityKey = 'jdoe' + Attributes = @{ + GivenName = 'John' + Surname = 'Doe' + Manager = 'jsmith' # Will be resolved to DN automatically + } + AuthSessionName = 'ActiveDirectory' + } +} +``` + +**Setting Manager via EnsureAttribute (UPN):** + +```powershell +@{ + Name = 'SetManager' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + Provider = 'Identity' + IdentityKey = 'jdoe' + Name = 'Manager' + Value = 'jsmith@contoso.local' # UPN - will be resolved to DN + AuthSessionName = 'ActiveDirectory' + } +} +``` + +**Clearing Manager:** + +To clear the Manager attribute, set the value to `$null`: + +```powershell +@{ + Name = 'ClearManager' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + Provider = 'Identity' + IdentityKey = 'jdoe' + Name = 'Manager' + Value = $null + AuthSessionName = 'ActiveDirectory' + } +} +``` + +**Note:** Clearing the Manager attribute using `$null` works correctly in PSD1 workflow files. PowerShell evaluates `$null` at load time. + ### Complete example workflows Complete example workflows are available in the repository: diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index c420d11a..34004851 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -44,6 +44,99 @@ function New-IdleADAdapter { return $escaped } -Force + # Add Manager DN validation helper + $adapter | Add-Member -MemberType ScriptMethod -Name TestManagerDN -Value { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $true + } + + if ($Value -isnot [string]) { + throw "Manager must be a DistinguishedName (DN) string, but received type: $($Value.GetType().FullName)" + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "Manager must be a DistinguishedName (DN) string, but received empty or whitespace-only value." + } + + if (-not ($Value -match '=' -and $Value -match ',')) { + throw "Manager must be a DistinguishedName (DN). Expected format: 'CN=Name,OU=Unit,DC=domain,DC=com'. Received: '$Value'" + } + + return $true + } -Force + + # Add Manager DN resolution helper + # Accepts DN, GUID, UPN, or sAMAccountName and resolves to DN + $adapter | Add-Member -MemberType ScriptMethod -Name ResolveManagerDN -Value { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -isnot [string]) { + throw "Manager must be a string (DN, GUID, UPN, or sAMAccountName), but received type: $($Value.GetType().FullName)" + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "Manager cannot be an empty or whitespace-only string." + } + + # Check if already a DN (contains = and ,) + if ($Value -match '=' -and $Value -match ',') { + # Already a DN, validate and return + $this.TestManagerDN($Value) | Out-Null + return $Value + } + + # Try to resolve as GUID, UPN, or sAMAccountName + Write-Verbose "Manager value '$Value' is not a DN. Attempting to resolve to DN..." + + # Try GUID format first + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($Value, [ref]$guid)) { + try { + $managerUser = $this.GetUserByGuid($guid.ToString()) + if ($null -ne $managerUser) { + Write-Verbose "Resolved Manager GUID '$Value' to DN: $($managerUser.DistinguishedName)" + return $managerUser.DistinguishedName + } + } + catch { + Write-Verbose "Failed to resolve Manager GUID '$Value': $_" + } + throw "Manager: Could not find user with GUID '$Value'." + } + + # Try UPN format (contains @) + if ($Value -match '@') { + $managerUser = $this.GetUserByUpn($Value) + if ($null -ne $managerUser) { + Write-Verbose "Resolved Manager UPN '$Value' to DN: $($managerUser.DistinguishedName)" + return $managerUser.DistinguishedName + } + throw "Manager: Could not find user with UPN '$Value'." + } + + # Fallback to sAMAccountName + $managerUser = $this.GetUserBySam($Value) + if ($null -ne $managerUser) { + Write-Verbose "Resolved Manager sAMAccountName '$Value' to DN: $($managerUser.DistinguishedName)" + return $managerUser.DistinguishedName + } + throw "Manager: Could not find user with sAMAccountName '$Value'." + } -Force + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { param( [Parameter(Mandatory)] @@ -56,7 +149,7 @@ function New-IdleADAdapter { $escapedUpn = $escapedUpn -replace '''', '''''' $params = @{ Filter = "UserPrincipalName -eq '$escapedUpn'" - Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName') + Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -85,7 +178,7 @@ function New-IdleADAdapter { $params = @{ Filter = "sAMAccountName -eq '$escapedSam'" - Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName') + Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -110,7 +203,7 @@ function New-IdleADAdapter { $params = @{ Identity = $Guid - Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName') + Properties = @('Enabled', 'DistinguishedName', 'ObjectGuid', 'UserPrincipalName', 'sAMAccountName', 'Manager') ErrorAction = 'Stop' } if ($null -ne $this.Credential) { @@ -241,6 +334,13 @@ function New-IdleADAdapter { if ($effectiveAttributes.ContainsKey('EmailAddress')) { $params['EmailAddress'] = $effectiveAttributes['EmailAddress'] } + if ($effectiveAttributes.ContainsKey('Manager')) { + $managerValue = $effectiveAttributes['Manager'] + $resolvedManagerDN = $this.ResolveManagerDN($managerValue) + if ($null -ne $resolvedManagerDN) { + $params['Manager'] = $resolvedManagerDN + } + } # Password handling: support SecureString, ProtectedString, and explicit PlainText $hasAccountPassword = $effectiveAttributes.ContainsKey('AccountPassword') @@ -346,6 +446,14 @@ function New-IdleADAdapter { 'Title' { $params['Title'] = $Value } 'EmailAddress' { $params['EmailAddress'] = $Value } 'UserPrincipalName' { $params['UserPrincipalName'] = $Value } + 'Manager' { + # Expect $Value to be a normalized DN or $null. + if ($null -eq $Value) { + $params['Clear'] = 'manager' + } else { + $params['Manager'] = $Value + } + } default { $params['Replace'] = @{ $AttributeName = $Value } } diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 44223a21..6127c700 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -48,6 +48,84 @@ Describe 'AD identity provider' { Store = $store } + # Add Manager DN validation helper (matching real adapter) + $adapter | Add-Member -MemberType ScriptMethod -Name TestManagerDN -Value { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $true + } + + if ($Value -isnot [string]) { + throw "Manager must be a DistinguishedName (DN) string, but received type: $($Value.GetType().FullName)" + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "Manager must be a DistinguishedName (DN) string, but received empty or whitespace-only value." + } + + if (-not ($Value -match '=' -and $Value -match ',')) { + throw "Manager must be a DistinguishedName (DN). Expected format: 'CN=Name,OU=Unit,DC=domain,DC=com'. Received: '$Value'" + } + + return $true + } -Force + + # Add Manager DN resolution helper (matching real adapter) + $adapter | Add-Member -MemberType ScriptMethod -Name ResolveManagerDN -Value { + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -isnot [string]) { + throw "Manager must be a string (DN, GUID, UPN, or sAMAccountName), but received type: $($Value.GetType().FullName)" + } + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "Manager cannot be an empty or whitespace-only string." + } + + # Check if already a DN (contains = and ,) + if ($Value -match '=' -and $Value -match ',') { + $this.TestManagerDN($Value) | Out-Null + return $Value + } + + # Try to resolve as GUID, UPN, or sAMAccountName + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($Value, [ref]$guid)) { + $managerUser = $this.GetUserByGuid($guid.ToString()) + if ($null -ne $managerUser) { + return $managerUser.DistinguishedName + } + throw "Manager: Could not find user with GUID '$Value'." + } + + if ($Value -match '@') { + $managerUser = $this.GetUserByUpn($Value) + if ($null -ne $managerUser) { + return $managerUser.DistinguishedName + } + throw "Manager: Could not find user with UPN '$Value'." + } + + $managerUser = $this.GetUserBySam($Value) + if ($null -ne $managerUser) { + return $managerUser.DistinguishedName + } + throw "Manager: Could not find user with sAMAccountName '$Value'." + } -Force + # Auto-creation behavior: The fake adapter auto-creates identities on lookup # to support provider contract tests (which expect this behavior from test providers). # This differs from the real AD adapter which will throw when an identity is not found. @@ -79,6 +157,7 @@ Describe 'AD identity provider' { Department = $null Title = $null EmailAddress = $null + Manager = $null Groups = @() } $this.Store[$guid] = $user @@ -108,6 +187,7 @@ Describe 'AD identity provider' { Department = $null Title = $null EmailAddress = $null + Manager = $null Groups = @() } $this.Store[$guid] = $user @@ -197,6 +277,12 @@ Describe 'AD identity provider' { } } + # Manager resolution (same as real adapter) + $resolvedManager = $null + if ($Attributes.ContainsKey('Manager')) { + $resolvedManager = $this.ResolveManagerDN($Attributes['Manager']) + } + $newGuid = [guid]::NewGuid().ToString() $sam = $Attributes['SamAccountName'] $upn = if ($Attributes.ContainsKey('UserPrincipalName')) { $Attributes['UserPrincipalName'] } else { "$sam@domain.local" } @@ -215,6 +301,7 @@ Describe 'AD identity provider' { Department = $Attributes['Department'] Title = $Attributes['Title'] EmailAddress = $Attributes['EmailAddress'] + Manager = $resolvedManager Groups = @() } @@ -237,6 +324,17 @@ Describe 'AD identity provider' { throw "User not found: $Identity" } + # Handle Manager attribute with resolution + if ($AttributeName -eq 'Manager') { + $resolvedManagerDN = $this.ResolveManagerDN($Value) + if ($null -eq $user.PSObject.Properties['Manager']) { + $user | Add-Member -MemberType NoteProperty -Name 'Manager' -Value $resolvedManagerDN -Force + } else { + $user.Manager = $resolvedManagerDN + } + return + } + # Handle known properties $knownProps = @('GivenName', 'Surname', 'DisplayName', 'Description', 'Department', 'Title', 'EmailAddress', 'UserPrincipalName') if ($AttributeName -in $knownProps -and $null -ne $user.PSObject.Properties[$AttributeName]) { @@ -1143,4 +1241,256 @@ Describe 'AD identity provider' { $user.DistinguishedName | Should -BeLike '*OU=Users,DC=domain,DC=local' } } + + Context 'Manager attribute handling' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:ManagerTestProvider = $provider + $script:ManagerTestAdapter = $adapter + } + + It 'CreateIdentity sets Manager when valid DN is provided' { + $attrs = @{ + SamAccountName = 'managertest1' + GivenName = 'Test' + Surname = 'User' + Manager = 'CN=Jane Doe,OU=Managers,DC=contoso,DC=com' + } + + $user = $script:ManagerTestAdapter.NewUser('managertest1', $attrs, $true) + + $user.Manager | Should -Be 'CN=Jane Doe,OU=Managers,DC=contoso,DC=com' + } + + It 'CreateIdentity throws when Manager GUID does not exist' { + # The fake adapter auto-creates users on sAMAccountName/UPN lookup for contract tests + # Use a non-existent GUID to test resolution failure + $nonExistentGuid = [guid]::NewGuid().ToString() + + $attrs = @{ + SamAccountName = 'managertest2' + Manager = $nonExistentGuid # Non-existent GUID + } + + { $script:ManagerTestAdapter.NewUser('managertest2', $attrs, $true) } | Should -Throw -ExpectedMessage '*Could not find user*' + } + + It 'CreateIdentity throws when Manager is not a string' { + $attrs = @{ + SamAccountName = 'managertest3' + Manager = 12345 + } + + { $script:ManagerTestAdapter.NewUser('managertest3', $attrs, $true) } | Should -Throw -ExpectedMessage '*Manager must be a string*' + } + + It 'CreateIdentity throws when Manager is empty string' { + $attrs = @{ + SamAccountName = 'managertest4' + Manager = ' ' + } + + { $script:ManagerTestAdapter.NewUser('managertest4', $attrs, $true) } | Should -Throw -ExpectedMessage '*Manager cannot be an empty*' + } + + It 'EnsureAttribute sets Manager when valid DN is provided' { + # Create user without manager + $attrs = @{ + SamAccountName = 'managertest5' + } + $user = $script:ManagerTestAdapter.NewUser('managertest5', $attrs, $true) + + # Set manager via EnsureAttribute (use sAMAccountName as IdentityKey) + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest5', + 'Manager', + 'CN=John Smith,OU=Managers,DC=contoso,DC=com', + $null + ) + + $result.Changed | Should -Be $true + + # Verify manager was set + $updatedUser = $script:ManagerTestAdapter.Store[$user.ObjectGuid.ToString()] + $updatedUser.Manager | Should -Be 'CN=John Smith,OU=Managers,DC=contoso,DC=com' + } + + It 'EnsureAttribute clears Manager when Value is $null' { + # Create user with manager + $attrs = @{ + SamAccountName = 'managertest6' + Manager = 'CN=Jane Doe,OU=Managers,DC=contoso,DC=com' + } + $user = $script:ManagerTestAdapter.NewUser('managertest6', $attrs, $true) + + # Clear manager via EnsureAttribute (use sAMAccountName as IdentityKey) + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest6', + 'Manager', + $null, + $null + ) + + $result.Changed | Should -Be $true + + # Verify manager was cleared + $updatedUser = $script:ManagerTestAdapter.Store[$user.ObjectGuid.ToString()] + $updatedUser.Manager | Should -BeNullOrEmpty + } + + It 'EnsureAttribute throws when Manager GUID does not exist' { + $attrs = @{ + SamAccountName = 'managertest7' + } + $user = $script:ManagerTestAdapter.NewUser('managertest7', $attrs, $true) + + # Use a non-existent GUID to test resolution failure + $nonExistentGuid = [guid]::NewGuid().ToString() + + { + $script:ManagerTestProvider.EnsureAttribute( + 'managertest7', + 'Manager', + $nonExistentGuid, # Non-existent GUID + $null + ) + } | Should -Throw -ExpectedMessage '*Could not find user*' + } + + It 'EnsureAttribute is idempotent when Manager is already set to target value' { + # Create user with manager + $attrs = @{ + SamAccountName = 'managertest8' + Manager = 'CN=Jane Doe,OU=Managers,DC=contoso,DC=com' + } + $user = $script:ManagerTestAdapter.NewUser('managertest8', $attrs, $true) + + # Set same manager again (use sAMAccountName as IdentityKey) + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest8', + 'Manager', + 'CN=Jane Doe,OU=Managers,DC=contoso,DC=com', + $null + ) + + $result.Changed | Should -Be $false + } + + It 'EnsureAttribute is idempotent when Manager is already cleared' { + # Create user without manager + $attrs = @{ + SamAccountName = 'managertest9' + } + $user = $script:ManagerTestAdapter.NewUser('managertest9', $attrs, $true) + + # Clear manager (already cleared) (use sAMAccountName as IdentityKey) + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest9', + 'Manager', + $null, + $null + ) + + $result.Changed | Should -Be $false + } + + It 'CreateIdentity resolves Manager from sAMAccountName' { + # First create a manager user + $managerAttrs = @{ + SamAccountName = 'jsmith' + GivenName = 'Jane' + Surname = 'Smith' + } + $managerUser = $script:ManagerTestAdapter.NewUser('jsmith', $managerAttrs, $true) + + # Create employee with manager specified by sAMAccountName + $attrs = @{ + SamAccountName = 'managertest10' + Manager = 'jsmith' # sAMAccountName instead of DN + } + $user = $script:ManagerTestAdapter.NewUser('managertest10', $attrs, $true) + + # Verify Manager was resolved to DN + $user.Manager | Should -Be $managerUser.DistinguishedName + } + + It 'CreateIdentity resolves Manager from UPN' { + # First create a manager user + $managerAttrs = @{ + SamAccountName = 'bwilson' + UserPrincipalName = 'bwilson@domain.local' + } + $managerUser = $script:ManagerTestAdapter.NewUser('bwilson', $managerAttrs, $true) + + # Create employee with manager specified by UPN + $attrs = @{ + SamAccountName = 'managertest11' + Manager = 'bwilson@domain.local' # UPN instead of DN + } + $user = $script:ManagerTestAdapter.NewUser('managertest11', $attrs, $true) + + # Verify Manager was resolved to DN + $user.Manager | Should -Be $managerUser.DistinguishedName + } + + It 'CreateIdentity resolves Manager from GUID' { + # First create a manager user + $managerAttrs = @{ + SamAccountName = 'tjones' + } + $managerUser = $script:ManagerTestAdapter.NewUser('tjones', $managerAttrs, $true) + + # Create employee with manager specified by GUID + $attrs = @{ + SamAccountName = 'managertest12' + Manager = $managerUser.ObjectGuid.ToString() # GUID instead of DN + } + $user = $script:ManagerTestAdapter.NewUser('managertest12', $attrs, $true) + + # Verify Manager was resolved to DN + $user.Manager | Should -Be $managerUser.DistinguishedName + } + + It 'EnsureAttribute resolves Manager from sAMAccountName' { + # Create a manager user + $managerAttrs = @{ + SamAccountName = 'mdavis' + } + $managerUser = $script:ManagerTestAdapter.NewUser('mdavis', $managerAttrs, $true) + + # Create employee without manager + $attrs = @{ + SamAccountName = 'managertest13' + } + $user = $script:ManagerTestAdapter.NewUser('managertest13', $attrs, $true) + + # Set manager via sAMAccountName + $result = $script:ManagerTestProvider.EnsureAttribute( + 'managertest13', + 'Manager', + 'mdavis', # sAMAccountName 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 + $nonExistentGuid = [guid]::NewGuid().ToString() + + $attrs = @{ + SamAccountName = 'managertest14' + Manager = $nonExistentGuid # Non-existent GUID + } + + { $script:ManagerTestAdapter.NewUser('managertest14', $attrs, $true) } | Should -Throw -ExpectedMessage '*Could not find user*' + } + } }