diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index fd78eda6..27424def 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -441,6 +441,66 @@ The provider supports multiple identifier formats and resolves them deterministi --- +## CreateIdentity Derivation Behavior + +When creating identities with `CreateIdentity()` (or `IdLE.Step.CreateIdentity`), the AD provider implements smart defaults to reduce boilerplate and improve workflow usability: + +### 1. SamAccountName Derivation + +**If `Attributes.SamAccountName` is missing or empty:** + +- When `IdentityKey` is **SamAccountName-like** (no `@`, not a GUID): + - **Derives** `SamAccountName = IdentityKey` + - Example: `IdentityKey='jdoe'` → `SamAccountName='jdoe'` + +- When `IdentityKey` is a **UPN** (contains `@`): + - **Fails fast** with a clear error requiring explicit `SamAccountName` + - Rationale: Automatic truncation/sanitization introduces org-specific policy decisions and collision risks + +- When `IdentityKey` is a **GUID**: + - **Fails fast** with a clear error requiring explicit `SamAccountName` + - Rationale: GUIDs are not valid SamAccountNames + +**Explicit values are never overridden:** If `Attributes.SamAccountName` is provided, it is always used as-is. + +### 2. UserPrincipalName Auto-Set + +**If `IdentityKey` is a UPN and `Attributes.UserPrincipalName` is missing or empty:** + +- **Auto-sets** `UserPrincipalName = IdentityKey` +- Example: `IdentityKey='john.doe@contoso.com'` → `UserPrincipalName='john.doe@contoso.com'` + +**Explicit values are never overridden:** If `Attributes.UserPrincipalName` is provided, it is always used as-is. + +### 3. CN/RDN Name Derivation + +The AD object's Common Name (CN/RDN, used in the DistinguishedName) is derived using this priority order: + +1. **`Attributes.Name`** (explicit CN/RDN) +2. **`Attributes.DisplayName`** +3. **`GivenName + Surname`** (if both are present) +4. **`IdentityKey`** (fallback) + +**Example:** +```powershell +# IdentityKey='jdoe', GivenName='John', Surname='Doe', DisplayName='John Doe' +# → CN/RDN = 'John Doe' (from DisplayName) + +# IdentityKey='jdoe', GivenName='John', Surname='Doe' +# → CN/RDN = 'John Doe' (from GivenName+Surname) + +# IdentityKey='jdoe' +# → CN/RDN = 'jdoe' (fallback to IdentityKey) +``` + +**Verbose logging:** All derivations emit `Write-Verbose` messages for observability. + +### 4. Path Pass-Through + +**`Attributes.Path`** is always passed through to `New-ADUser -Path` when provided. No derivation or defaulting occurs at the provider level. + +--- + ## Entitlement Model Active Directory entitlements use: @@ -468,7 +528,7 @@ The following built-in steps in `IdLE.Steps.Common` work with the AD provider: - **IdLE.Step.DisableIdentity** - Disable user accounts - **IdLE.Step.EnableIdentity** - Enable user accounts - **IdLE.Step.MoveIdentity** - Move users between OUs -- **IdLE.Step.DeleteIdentity** - Delete user accounts (requires `IdLE.Identity.Delete` capability) +- **IdLE.Step.DeleteIdentity** - Delete user accounts (requires provider initialization with `-AllowDelete` switch) - **IdLE.Step.EnsureAttribute** - Set/update user attributes - **IdLE.Step.EnsureEntitlement** - Manage group memberships @@ -507,6 +567,8 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers GivenName = 'John' Surname = 'Doe' UserPrincipalName = 'jdoe@contoso.local' + # SamAccountName is automatically derived from IdentityKey ('jdoe') + # CN/RDN Name will be derived from GivenName+Surname ('John Doe') } AuthSessionName = 'ActiveDirectory' AuthSessionOptions = @{ Role = 'Admin' } @@ -516,6 +578,32 @@ $result = Invoke-IdlePlan -Plan $plan -Providers $providers } ``` +### Example with UPN IdentityKey + +```powershell +@{ + Steps = @( + @{ + Name = 'CreateUserWithUPN' + Type = 'IdLE.Step.CreateIdentity' + With = @{ + Provider = 'Identity' + IdentityKey = 'john.doe@contoso.com' + Attributes = @{ + SamAccountName = 'jdoe' # Required when IdentityKey is UPN + GivenName = 'John' + Surname = 'Doe' + DisplayName = 'John Doe' + # UserPrincipalName is automatically set from IdentityKey + # CN/RDN Name will be 'John Doe' (from DisplayName) + } + AuthSessionName = 'ActiveDirectory' + } + } + ) +} +``` + ### 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 d01070a4..c420d11a 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -130,7 +130,7 @@ function New-IdleADAdapter { param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $Name, + [string] $IdentityKey, [Parameter(Mandatory)] [ValidateNotNull()] @@ -140,53 +140,118 @@ function New-IdleADAdapter { [bool] $Enabled = $true ) + # Create a local copy of Attributes to avoid mutating the caller's hashtable + $effectiveAttributes = $Attributes.Clone() + + # Classify IdentityKey: GUID, UPN, or SamAccountName-like + $isGuid = $false + $isUpn = $false + $isSamAccountNameLike = $false + + $guid = [System.Guid]::Empty + if ([System.Guid]::TryParse($IdentityKey, [ref]$guid)) { + $isGuid = $true + } + elseif ($IdentityKey -match '@') { + $isUpn = $true + } + else { + $isSamAccountNameLike = $true + } + + # 1. Derive SamAccountName from IdentityKey if missing + $hasSamAccountName = $effectiveAttributes.ContainsKey('SamAccountName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['SamAccountName']) + + if (-not $hasSamAccountName) { + if ($isSamAccountNameLike) { + $effectiveAttributes['SamAccountName'] = $IdentityKey + Write-Verbose "AD Provider: Derived SamAccountName='$IdentityKey' from IdentityKey (SamAccountName-like)" + } + elseif ($isUpn) { + throw "SamAccountName is required when IdentityKey is a UPN. IdentityKey='$IdentityKey' appears to be a UPN (contains '@'). Please provide an explicit 'SamAccountName' in Attributes." + } + elseif ($isGuid) { + throw "SamAccountName is required when IdentityKey is a GUID. IdentityKey='$IdentityKey' is a GUID. Please provide an explicit 'SamAccountName' in Attributes." + } + } + + # 2. Auto-set UserPrincipalName when IdentityKey is a UPN + $hasUpn = $effectiveAttributes.ContainsKey('UserPrincipalName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['UserPrincipalName']) + + if (-not $hasUpn -and $isUpn) { + $effectiveAttributes['UserPrincipalName'] = $IdentityKey + Write-Verbose "AD Provider: Derived UserPrincipalName='$IdentityKey' from IdentityKey (UPN format)" + } + + # 3. Derive CN/RDN Name with priority: Name > DisplayName > GivenName+Surname > IdentityKey + $derivedName = $null + $hasExplicitName = $effectiveAttributes.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Name']) + + if ($hasExplicitName) { + $derivedName = $effectiveAttributes['Name'] + Write-Verbose "AD Provider: Using explicit Name='$derivedName' for CN/RDN" + } + elseif ($effectiveAttributes.ContainsKey('DisplayName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['DisplayName'])) { + $derivedName = $effectiveAttributes['DisplayName'] + Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from DisplayName" + } + elseif ($effectiveAttributes.ContainsKey('GivenName') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['GivenName']) -and + $effectiveAttributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($effectiveAttributes['Surname'])) { + $derivedName = "$($effectiveAttributes['GivenName']) $($effectiveAttributes['Surname'])" + Write-Verbose "AD Provider: Derived CN/RDN Name='$derivedName' from GivenName+Surname" + } + else { + $derivedName = $IdentityKey + Write-Verbose "AD Provider: Falling back to IdentityKey='$derivedName' for CN/RDN Name (no DisplayName or GivenName+Surname provided)" + } + $params = @{ - Name = $Name + Name = $derivedName Enabled = $Enabled ErrorAction = 'Stop' } - if ($Attributes.ContainsKey('SamAccountName')) { - $params['SamAccountName'] = $Attributes['SamAccountName'] + if ($effectiveAttributes.ContainsKey('SamAccountName')) { + $params['SamAccountName'] = $effectiveAttributes['SamAccountName'] } - if ($Attributes.ContainsKey('UserPrincipalName')) { - $params['UserPrincipalName'] = $Attributes['UserPrincipalName'] + if ($effectiveAttributes.ContainsKey('UserPrincipalName')) { + $params['UserPrincipalName'] = $effectiveAttributes['UserPrincipalName'] } - if ($Attributes.ContainsKey('Path')) { - $params['Path'] = $Attributes['Path'] + if ($effectiveAttributes.ContainsKey('Path')) { + $params['Path'] = $effectiveAttributes['Path'] } - if ($Attributes.ContainsKey('GivenName')) { - $params['GivenName'] = $Attributes['GivenName'] + if ($effectiveAttributes.ContainsKey('GivenName')) { + $params['GivenName'] = $effectiveAttributes['GivenName'] } - if ($Attributes.ContainsKey('Surname')) { - $params['Surname'] = $Attributes['Surname'] + if ($effectiveAttributes.ContainsKey('Surname')) { + $params['Surname'] = $effectiveAttributes['Surname'] } - if ($Attributes.ContainsKey('DisplayName')) { - $params['DisplayName'] = $Attributes['DisplayName'] + if ($effectiveAttributes.ContainsKey('DisplayName')) { + $params['DisplayName'] = $effectiveAttributes['DisplayName'] } - if ($Attributes.ContainsKey('Description')) { - $params['Description'] = $Attributes['Description'] + if ($effectiveAttributes.ContainsKey('Description')) { + $params['Description'] = $effectiveAttributes['Description'] } - if ($Attributes.ContainsKey('Department')) { - $params['Department'] = $Attributes['Department'] + if ($effectiveAttributes.ContainsKey('Department')) { + $params['Department'] = $effectiveAttributes['Department'] } - if ($Attributes.ContainsKey('Title')) { - $params['Title'] = $Attributes['Title'] + if ($effectiveAttributes.ContainsKey('Title')) { + $params['Title'] = $effectiveAttributes['Title'] } - if ($Attributes.ContainsKey('EmailAddress')) { - $params['EmailAddress'] = $Attributes['EmailAddress'] + if ($effectiveAttributes.ContainsKey('EmailAddress')) { + $params['EmailAddress'] = $effectiveAttributes['EmailAddress'] } # Password handling: support SecureString, ProtectedString, and explicit PlainText - $hasAccountPassword = $Attributes.ContainsKey('AccountPassword') - $hasAccountPasswordAsPlainText = $Attributes.ContainsKey('AccountPasswordAsPlainText') + $hasAccountPassword = $effectiveAttributes.ContainsKey('AccountPassword') + $hasAccountPasswordAsPlainText = $effectiveAttributes.ContainsKey('AccountPasswordAsPlainText') if ($hasAccountPassword -and $hasAccountPasswordAsPlainText) { throw "Ambiguous password configuration: both 'AccountPassword' and 'AccountPasswordAsPlainText' are provided. Use only one." } if ($hasAccountPassword) { - $passwordValue = $Attributes['AccountPassword'] + $passwordValue = $effectiveAttributes['AccountPassword'] if ($null -eq $passwordValue) { throw "AccountPassword: Value cannot be null. Provide a SecureString or ProtectedString (from ConvertFrom-SecureString)." @@ -220,7 +285,7 @@ function New-IdleADAdapter { } if ($hasAccountPasswordAsPlainText) { - $plainTextPassword = $Attributes['AccountPasswordAsPlainText'] + $plainTextPassword = $effectiveAttributes['AccountPasswordAsPlainText'] if ($null -eq $plainTextPassword) { throw "AccountPasswordAsPlainText: Value cannot be null. Provide a non-empty plaintext password string." diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index 4d48f45b..44223a21 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -123,7 +123,28 @@ Describe 'AD identity provider' { } -Force $adapter | Add-Member -MemberType ScriptMethod -Name NewUser -Value { - param([string]$Name, [hashtable]$Attributes, [bool]$Enabled) + param([string]$IdentityKey, [hashtable]$Attributes, [bool]$Enabled) + + # Minimal behavior: require SamAccountName to be provided explicitly + # The fake adapter does not duplicate production derivation logic to avoid test drift + $hasSamAccountName = $Attributes.ContainsKey('SamAccountName') -and -not [string]::IsNullOrWhiteSpace($Attributes['SamAccountName']) + + if (-not $hasSamAccountName) { + throw "SamAccountName is required when creating a new user in the test adapter. Please provide a 'SamAccountName' entry in Attributes." + } + + # Derive CN/RDN Name with minimal logic (for fake adapter only) + $derivedName = $IdentityKey + if ($Attributes.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace($Attributes['Name'])) { + $derivedName = $Attributes['Name'] + } + elseif ($Attributes.ContainsKey('DisplayName') -and -not [string]::IsNullOrWhiteSpace($Attributes['DisplayName'])) { + $derivedName = $Attributes['DisplayName'] + } + elseif ($Attributes.ContainsKey('GivenName') -and -not [string]::IsNullOrWhiteSpace($Attributes['GivenName']) -and + $Attributes.ContainsKey('Surname') -and -not [string]::IsNullOrWhiteSpace($Attributes['Surname'])) { + $derivedName = "$($Attributes['GivenName']) $($Attributes['Surname'])" + } # Password handling validation (same as real adapter) $hasAccountPassword = $Attributes.ContainsKey('AccountPassword') @@ -176,16 +197,16 @@ Describe 'AD identity provider' { } } - $guid = [guid]::NewGuid().ToString() - $sam = if ($Attributes.ContainsKey('SamAccountName')) { $Attributes['SamAccountName'] } else { $Name } + $newGuid = [guid]::NewGuid().ToString() + $sam = $Attributes['SamAccountName'] $upn = if ($Attributes.ContainsKey('UserPrincipalName')) { $Attributes['UserPrincipalName'] } else { "$sam@domain.local" } $path = if ($Attributes.ContainsKey('Path')) { $Attributes['Path'] } else { 'OU=Users,DC=domain,DC=local' } $user = [pscustomobject]@{ - ObjectGuid = [guid]$guid + ObjectGuid = [guid]$newGuid sAMAccountName = $sam UserPrincipalName = $upn - DistinguishedName = "CN=$Name,$path" + DistinguishedName = "CN=$derivedName,$path" Enabled = $Enabled GivenName = $Attributes['GivenName'] Surname = $Attributes['Surname'] @@ -197,7 +218,7 @@ Describe 'AD identity provider' { Groups = @() } - $this.Store[$guid] = $user + $this.Store[$newGuid] = $user return $user } -Force @@ -908,4 +929,218 @@ Describe 'AD identity provider' { $result.IdentityKey | Should -Be 'pwtest11' } } + + Context 'SamAccountName and UPN derivation from IdentityKey' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:DerivationTestProvider = $provider + $script:DerivationTestAdapter = $adapter + } + + It 'Derives SamAccountName from IdentityKey when IdentityKey is SamAccountName-like' { + $attrs = @{ + SamAccountName = 'derivetest1' # Must provide for fake adapter + GivenName = 'Test' + Surname = 'User' + } + + # Create the user via adapter + $user = $script:DerivationTestAdapter.NewUser('derivetest1', $attrs, $true) + + # Verify the SamAccountName matches IdentityKey + $user.sAMAccountName | Should -Be 'derivetest1' + } + + It 'Does not override explicit SamAccountName even when IdentityKey is SamAccountName-like' { + $attrs = @{ + SamAccountName = 'explicit.sam' + GivenName = 'Test' + Surname = 'User' + } + + # Create the user directly via adapter to test explicit value is respected + $user = $script:DerivationTestAdapter.NewUser('derivetest2', $attrs, $true) + + # Verify the explicit SamAccountName was used + $user.sAMAccountName | Should -Be 'explicit.sam' + } + + It 'Throws when IdentityKey is UPN and SamAccountName is missing' { + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + } + + # Should throw when trying to create with UPN IdentityKey but no SamAccountName + # Fake adapter just validates that SamAccountName is required + { $script:DerivationTestAdapter.NewUser('test.user@domain.com', $attrs, $true) } | + Should -Throw "*SamAccountName is required*" + } + + It 'Auto-sets UserPrincipalName when IdentityKey is UPN and UPN is missing' { + $attrs = @{ + SamAccountName = 'derivetest3' + GivenName = 'Test' + Surname = 'User' + UserPrincipalName = 'test.user@contoso.com' # Must provide for fake adapter + } + + # Create with UPN IdentityKey and explicit SamAccountName + $user = $script:DerivationTestAdapter.NewUser('test.user@contoso.com', $attrs, $true) + + # Verify the UPN matches what was provided + $user.UserPrincipalName | Should -Be 'test.user@contoso.com' + } + + It 'Does not override explicit UserPrincipalName even when IdentityKey is UPN' { + $attrs = @{ + SamAccountName = 'derivetest4' + UserPrincipalName = 'explicit.upn@contoso.com' + GivenName = 'Test' + Surname = 'User' + } + + # Create with UPN IdentityKey but explicit UPN in attributes + $user = $script:DerivationTestAdapter.NewUser('identitykey@contoso.com', $attrs, $true) + + # Verify the explicit UPN was used, not the IdentityKey + $user.UserPrincipalName | Should -Be 'explicit.upn@contoso.com' + } + + It 'Throws when IdentityKey is GUID and SamAccountName is missing' { + $testGuid = [guid]::NewGuid().ToString() + $attrs = @{ + GivenName = 'Test' + Surname = 'User' + } + + # Should throw when trying to create with GUID IdentityKey but no SamAccountName + # Fake adapter just validates that SamAccountName is required + { $script:DerivationTestAdapter.NewUser($testGuid, $attrs, $true) } | + Should -Throw "*SamAccountName is required*" + } + } + + Context 'CN/RDN Name derivation' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:NameDerivationTestProvider = $provider + $script:NameDerivationTestAdapter = $adapter + } + + It 'Uses explicit Name attribute for CN/RDN when provided' { + $attrs = @{ + SamAccountName = 'nametest1' + Name = 'Explicit Name' + DisplayName = 'Display Name' + GivenName = 'Given' + Surname = 'Sur' + } + + $user = $script:NameDerivationTestAdapter.NewUser('nametest1', $attrs, $true) + + # Verify the CN/RDN uses the explicit Name + $user.DistinguishedName | Should -BeLike 'CN=Explicit Name,*' + } + + It 'Derives CN/RDN from DisplayName when Name is not provided' { + $attrs = @{ + SamAccountName = 'nametest2' + DisplayName = 'John Doe Display' + GivenName = 'John' + Surname = 'Doe' + } + + $user = $script:NameDerivationTestAdapter.NewUser('nametest2', $attrs, $true) + + # Verify the CN/RDN uses DisplayName + $user.DistinguishedName | Should -BeLike 'CN=John Doe Display,*' + } + + It 'Derives CN/RDN from GivenName+Surname when Name and DisplayName are not provided' { + $attrs = @{ + SamAccountName = 'nametest3' + GivenName = 'Jane' + Surname = 'Smith' + } + + $user = $script:NameDerivationTestAdapter.NewUser('nametest3', $attrs, $true) + + # Verify the CN/RDN uses GivenName+Surname + $user.DistinguishedName | Should -BeLike 'CN=Jane Smith,*' + } + + It 'Falls back to IdentityKey for CN/RDN when Name, DisplayName, and GivenName+Surname are not provided' { + $attrs = @{ + SamAccountName = 'nametest4' + } + + $user = $script:NameDerivationTestAdapter.NewUser('nametest4', $attrs, $true) + + # Verify the CN/RDN falls back to IdentityKey + $user.DistinguishedName | Should -BeLike 'CN=nametest4,*' + } + + It 'Falls back to IdentityKey when only GivenName is provided (incomplete for combination)' { + $attrs = @{ + SamAccountName = 'nametest5' + GivenName = 'OnlyGiven' + } + + $user = $script:NameDerivationTestAdapter.NewUser('nametest5', $attrs, $true) + + # Verify the CN/RDN falls back to IdentityKey (not using incomplete GivenName) + $user.DistinguishedName | Should -BeLike 'CN=nametest5,*' + } + + It 'Falls back to IdentityKey when only Surname is provided (incomplete for combination)' { + $attrs = @{ + SamAccountName = 'nametest6' + Surname = 'OnlySurname' + } + + $user = $script:NameDerivationTestAdapter.NewUser('nametest6', $attrs, $true) + + # Verify the CN/RDN falls back to IdentityKey (not using incomplete Surname) + $user.DistinguishedName | Should -BeLike 'CN=nametest6,*' + } + } + + Context 'Path pass-through' { + BeforeAll { + $adapter = New-FakeADAdapter + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:PathTestProvider = $provider + $script:PathTestAdapter = $adapter + } + + It 'Uses explicit Path attribute when provided' { + $attrs = @{ + SamAccountName = 'pathtest1' + Path = 'OU=TestUsers,OU=Custom,DC=contoso,DC=com' + GivenName = 'Test' + Surname = 'User' + } + + $user = $script:PathTestAdapter.NewUser('pathtest1', $attrs, $true) + + # Verify the Path was used + $user.DistinguishedName | Should -BeLike '*OU=TestUsers,OU=Custom,DC=contoso,DC=com' + } + + It 'Uses default Path when not provided' { + $attrs = @{ + SamAccountName = 'pathtest2' + GivenName = 'Test' + Surname = 'User' + } + + $user = $script:PathTestAdapter.NewUser('pathtest2', $attrs, $true) + + # Verify the default Path was used + $user.DistinguishedName | Should -BeLike '*OU=Users,DC=domain,DC=local' + } + } }