diff --git a/docs/reference/providers/provider-ad.md b/docs/reference/providers/provider-ad.md index 62ee523d..d4d879bf 100644 --- a/docs/reference/providers/provider-ad.md +++ b/docs/reference/providers/provider-ad.md @@ -288,11 +288,112 @@ The following attributes are supported when creating identities via `CreateIdent #### Password attributes - `AccountPassword` (SecureString or ProtectedString) - Password as SecureString or DPAPI-protected string - `AccountPasswordAsPlainText` (string) - Plaintext password (explicit opt-in, automatically redacted in events) +- `ResetOnFirstLogin` (boolean) - Require password change on first login (default: `$true` when password is set/generated) +- `AllowPlainTextPasswordOutput` (boolean) - Include plaintext password in result (opt-in, default: `$false`) :::warning Only one password attribute can be used at a time. Using both `AccountPassword` and `AccountPasswordAsPlainText` will throw an error. ::: +##### Automatic Password Generation + +When creating enabled accounts (`Enabled = $true`) without providing a password, the provider automatically generates a policy-compliant password: + +1. **Domain Policy Query**: Attempts to read domain password policy via `Get-ADDefaultDomainPasswordPolicy` +2. **Fallback Configuration**: Uses provider-configured fallback rules if policy cannot be read +3. **Defense in Depth**: Enforces fallback minimum length as baseline even when domain policy allows weaker passwords +4. **Complexity Requirements**: Generates passwords with uppercase, lowercase, digits, and special characters + +**Provider Configuration** (optional fallback parameters): +```powershell +$provider = New-IdleADIdentityProvider ` + -PasswordGenerationFallbackMinLength 32 ` + -PasswordGenerationRequireUpper $true ` + -PasswordGenerationRequireLower $true ` + -PasswordGenerationRequireDigit $true ` + -PasswordGenerationRequireSpecial $true ` + -PasswordGenerationSpecialCharSet '!@#$%^&*()' +``` + +**Default fallback values**: +- `PasswordGenerationFallbackMinLength`: 24 +- `PasswordGenerationRequire{Upper,Lower,Digit,Special}`: `$true` +- `PasswordGenerationSpecialCharSet`: `'!@#$%&*+-_=?'` + +##### Password Output Control + +By default, generated passwords are returned as **ProtectedString** (DPAPI-scoped) for secure reveal: + +```powershell +# Default: secure ProtectedString output +$result = $provider.CreateIdentity('jdoe@contoso.com', @{ + SamAccountName = 'jdoe' + GivenName = 'John' + Surname = 'Doe' + Enabled = $true +}) + +# Password was generated +$result.PasswordGenerated # $true +$result.PasswordGenerationPolicyUsed # 'DomainPolicy' or 'Fallback' +$result.GeneratedAccountPasswordProtected # DPAPI-scoped ProtectedString +``` + +**Reveal Path** (decrypt ProtectedString when needed): +```powershell +$protectedPwd = $result.GeneratedAccountPasswordProtected +$securePwd = ConvertTo-SecureString -String $protectedPwd +$plainPwd = [pscredential]::new('x', $securePwd).GetNetworkCredential().Password +``` + +:::warning DPAPI Scope +ProtectedString uses Windows DPAPI and can only be decrypted by the same Windows user on the same machine. Do not transfer ProtectedStrings across machines or user contexts. +::: + +**Opt-in Plaintext Output**: + +For scenarios requiring immediate plaintext access (e.g., displaying to onboarding staff), set `AllowPlainTextPasswordOutput = $true`: + +```powershell +$result = $provider.CreateIdentity('jsmith@contoso.com', @{ + SamAccountName = 'jsmith' + GivenName = 'Jane' + Surname = 'Smith' + Enabled = $true + AllowPlainTextPasswordOutput = $true +}) + +# Plaintext is included in result (redacted from logs/events) +$plainPwd = $result.GeneratedAccountPasswordPlainText +``` + +:::danger Security Warning +Results containing `GeneratedAccountPasswordPlainText` must not be persisted to disk, logs, or databases. The value is automatically redacted from engine events and exports but is accessible in the immediate result object. Handle with care. +::: + +##### Reset on First Login + +Control whether users must change password on first login: + +- `ResetOnFirstLogin` (boolean) - Default: `$true` when password is set/generated + - Maps to AD "User must change password at next logon" + - Can be explicitly set to `$false` for scenarios like hybrid remote login + +```powershell +# Default: user must change password on first login +$result = $provider.CreateIdentity('user@contoso.com', @{ + SamAccountName = 'user1' + Enabled = $true +}) + +# Disable reset requirement +$result = $provider.CreateIdentity('admin@contoso.com', @{ + SamAccountName = 'admin1' + Enabled = $true + ResetOnFirstLogin = $false +}) +``` + #### State attributes - `Enabled` (boolean) - Account enabled state (default: `$true`) diff --git a/docs/reference/providers/provider-entraID.md b/docs/reference/providers/provider-entraID.md index 66577b99..8b203629 100644 --- a/docs/reference/providers/provider-entraID.md +++ b/docs/reference/providers/provider-entraID.md @@ -525,12 +525,83 @@ These attributes can be set via `CreateIdentity` and `EnsureAttributes`: | `OfficeLocation` | `officeLocation` | Office location | | `CompanyName` | `companyName` | Company name | | `MailNickname` | `mailNickname` | Mail alias (auto-generated if not provided) | -| `PasswordProfile` | `passwordProfile` | Password policy for new users | +| `PasswordProfile` | `passwordProfile` | Password policy for new users (create only) | | `Enabled` | `accountEnabled` | Account enabled state | ### Password Policy (Create Only) -When creating users, provide a `PasswordProfile`: +#### Automatic Password Generation + +When creating users without providing a `PasswordProfile`, the provider automatically generates a secure initial password using GUID format (e.g., `3f2504e0-4f89-11d3-9a0c-0305e82c3301`). GUID passwords: + +- Satisfy Entra ID default password complexity requirements while consisting of lowercase letters, digits, and hyphens (`[a-f0-9-]`) +- Are 36 characters long +- Set `forceChangePasswordNextSignIn = $true` by default (user must change on first login) + +**Generated password is returned** in the result with controlled output options: + +```powershell +# Default: secure ProtectedString output +$result = $provider.CreateIdentity('newuser@contoso.com', @{ + UserPrincipalName = 'newuser@contoso.com' + DisplayName = 'New User' + # No PasswordProfile provided - password auto-generated +}) + +# Access the generated password +$result.PasswordGenerated # $true +$result.PasswordGenerationMethod # 'GUID' +$result.GeneratedAccountPasswordProtected # DPAPI-scoped ProtectedString +``` + +#### Reveal Path + +To reveal the password from ProtectedString when needed: + +```powershell +$protectedPwd = $result.GeneratedAccountPasswordProtected +$securePwd = ConvertTo-SecureString -String $protectedPwd +$plainPwd = [pscredential]::new('x', $securePwd).GetNetworkCredential().Password +``` + +:::warning DPAPI Scope +ProtectedString uses Windows DPAPI and can only be decrypted by the same Windows user on the same machine. Do not transfer ProtectedStrings across machines or user contexts. +::: + +#### Opt-in Plaintext Output + +For scenarios requiring immediate plaintext access (e.g., displaying to onboarding staff), set `AllowPlainTextPasswordOutput = $true`: + +```powershell +$result = $provider.CreateIdentity('user@contoso.com', @{ + UserPrincipalName = 'user@contoso.com' + DisplayName = 'User Name' + AllowPlainTextPasswordOutput = $true +}) + +# Plaintext is included in result (redacted from logs/events) +$plainPwd = $result.GeneratedAccountPasswordPlainText +``` + +:::danger Security Warning +Results containing `GeneratedAccountPasswordPlainText` must not be persisted to disk, logs, or databases. The value is automatically redacted from engine events and exports but is accessible in the immediate result object. Handle with care. +::: + +#### Control Password Reset Requirement + +By default, generated passwords require change on first sign-in. To disable this (e.g., for service accounts): + +```powershell +$result = $provider.CreateIdentity('serviceaccount@contoso.com', @{ + UserPrincipalName = 'serviceaccount@contoso.com' + DisplayName = 'Service Account' + ForceChangePasswordNextSignIn = $false +}) +``` + +#### Explicit Password + +When creating users, you can provide an explicit `PasswordProfile`: ```powershell $attributes = @{ @@ -543,7 +614,7 @@ $attributes = @{ } ``` -If not provided, a random password is generated with `forceChangePasswordNextSignIn = $true`. +When `PasswordProfile` is explicitly provided, no password generation occurs and no password information is returned in the result. ## Paging diff --git a/examples/workflows/templates/ad-joiner-complete.psd1 b/examples/workflows/templates/ad-joiner-complete.psd1 index e7862686..b75135a0 100644 --- a/examples/workflows/templates/ad-joiner-complete.psd1 +++ b/examples/workflows/templates/ad-joiner-complete.psd1 @@ -15,6 +15,23 @@ DisplayName = 'New User' Description = 'New employee account' Path = 'OU=Joiners,OU=Users,DC=contoso,DC=local' + + # Enable account to trigger automatic password generation + # When Enabled = $true and no password is provided, the provider automatically: + # - Reads domain password policy via Get-ADDefaultDomainPasswordPolicy + # - Falls back to configurable rules if policy cannot be read + # - Generates a compliant password (min length 24, complexity enabled) + # - Returns GeneratedAccountPasswordProtected (DPAPI-scoped) by default + Enabled = $true + + # Optional: Request plaintext password in result (for displaying to onboarding staff) + # WARNING: Results containing plaintext must not be persisted to disk/logs + # AllowPlainTextPasswordOutput = $true + + # Optional: Disable password reset on first login (default: $true) + # Useful for hybrid scenarios where remote login may require stable password + # ResetOnFirstLogin = $false + OtherAttributes = @{ # Custom LDAP attributes for organization-specific needs employeeType = 'Employee' @@ -27,6 +44,15 @@ # If omitted, defaults to 'Identity'. Provider = 'Identity' } + # After execution, when password was generated, the result will contain: + # - PasswordGenerated: $true + # - PasswordGenerationPolicyUsed: 'DomainPolicy' or 'Fallback' + # - GeneratedAccountPasswordProtected: DPAPI-scoped ProtectedString (safe for reveal) + # - GeneratedAccountPasswordPlainText: (only if AllowPlainTextPasswordOutput = $true) + # + # To reveal the password from ProtectedString: + # $secure = ConvertTo-SecureString -String $result.GeneratedAccountPasswordProtected + # $plain = [pscredential]::new('x', $secure).GetNetworkCredential().Password }, @{ Name = 'Set Department and Title' diff --git a/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 b/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 index 21dc7684..7a4d8626 100644 --- a/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 +++ b/src/IdLE.Core/Private/Copy-IdleRedactedObject.ps1 @@ -17,6 +17,9 @@ function Copy-IdleRedactedObject { # Default key list aligned with Issue #48 acceptance criteria. # Keep this list conservative (exact match) to avoid accidental over-redaction. + # Note: These fields are redacted when objects pass through logging/eventing paths. + # They do NOT prevent direct access when explicitly requested (e.g., AllowPlainTextPasswordOutput). + # Redaction protects against accidental leakage, not intentional access by callers. $defaultKeys = @( 'password', 'passphrase', @@ -30,7 +33,9 @@ function Copy-IdleRedactedObject { 'credential', 'privateKey', 'AccountPassword', - 'AccountPasswordAsPlainText' + 'AccountPasswordAsPlainText', + 'GeneratedAccountPasswordPlainText', + 'GeneratedAccountPasswordProtected' ) $effectiveKeys = if ($null -ne $RedactedKeys -and $RedactedKeys.Count -gt 0) { diff --git a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 index bbcc5078..0a1753bd 100644 --- a/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 +++ b/src/IdLE.Provider.AD/Private/Get-IdleADAttributeContract.ps1 @@ -53,6 +53,8 @@ function Get-IdleADAttributeContract { # 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 } # State Attributes Enabled = @{ Target = 'Parameter'; Type = 'Boolean'; Required = $false } diff --git a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 index bd3000f9..e82c8cfb 100644 --- a/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 +++ b/src/IdLE.Provider.AD/Private/New-IdleADAdapter.ps1 @@ -9,6 +9,24 @@ function New-IdleADAdapter { .PARAMETER Credential Optional PSCredential for AD operations. If not provided, uses integrated auth. + + .PARAMETER PasswordGenerationFallbackMinLength + Fallback minimum password length when domain policy cannot be read. Default is 24. + + .PARAMETER PasswordGenerationRequireUpper + Fallback requirement for uppercase characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationRequireLower + Fallback requirement for lowercase characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationRequireDigit + Fallback requirement for digit characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationRequireSpecial + Fallback requirement for special characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationSpecialCharSet + Set of special characters to use in generated passwords. Default is '!@#$%&*+-_=?'. .NOTES PSScriptAnalyzer suppression: This function intentionally uses ConvertTo-SecureString -AsPlainText @@ -20,12 +38,36 @@ function New-IdleADAdapter { param( [Parameter()] [AllowNull()] - [PSCredential] $Credential + [PSCredential] $Credential, + + [Parameter()] + [int] $PasswordGenerationFallbackMinLength = 24, + + [Parameter()] + [bool] $PasswordGenerationRequireUpper = $true, + + [Parameter()] + [bool] $PasswordGenerationRequireLower = $true, + + [Parameter()] + [bool] $PasswordGenerationRequireDigit = $true, + + [Parameter()] + [bool] $PasswordGenerationRequireSpecial = $true, + + [Parameter()] + [string] $PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?' ) $adapter = [pscustomobject]@{ PSTypeName = 'IdLE.ADAdapter' Credential = $Credential + PasswordGenerationFallbackMinLength = $PasswordGenerationFallbackMinLength + PasswordGenerationRequireUpper = $PasswordGenerationRequireUpper + PasswordGenerationRequireLower = $PasswordGenerationRequireLower + PasswordGenerationRequireDigit = $PasswordGenerationRequireDigit + PasswordGenerationRequireSpecial = $PasswordGenerationRequireSpecial + PasswordGenerationSpecialCharSet = $PasswordGenerationSpecialCharSet } # Add LDAP filter escaping as a ScriptMethod to make it available in the adapter's scope @@ -342,9 +384,10 @@ function New-IdleADAdapter { } } - # Password handling: support SecureString, ProtectedString, and explicit PlainText + # Password handling: support SecureString, ProtectedString, explicit PlainText, and auto-generation $hasAccountPassword = $effectiveAttributes.ContainsKey('AccountPassword') $hasAccountPasswordAsPlainText = $effectiveAttributes.ContainsKey('AccountPasswordAsPlainText') + $generatedPasswordInfo = $null if ($hasAccountPassword -and $hasAccountPasswordAsPlainText) { throw "Ambiguous password configuration: both 'AccountPassword' and 'AccountPasswordAsPlainText' are provided. Use only one." @@ -383,8 +426,7 @@ function New-IdleADAdapter { throw "AccountPassword: Expected a SecureString or ProtectedString (string from ConvertFrom-SecureString), but received type: $($passwordValue.GetType().FullName)" } } - - if ($hasAccountPasswordAsPlainText) { + elseif ($hasAccountPasswordAsPlainText) { $plainTextPassword = $effectiveAttributes['AccountPasswordAsPlainText'] if ($null -eq $plainTextPassword) { @@ -404,6 +446,41 @@ function New-IdleADAdapter { # The value is redacted from logs/events via Copy-IdleRedactedObject. $params['AccountPassword'] = ConvertTo-SecureString -String $plainTextPassword -AsPlainText -Force } + elseif ($Enabled) { + # Mode 4: Auto-generate password when enabled and no password provided + # Generate a policy-compliant password + Write-Verbose "AD Provider: No password provided for enabled account. Generating policy-compliant password..." + + $passwordGenParams = @{ + FallbackMinLength = $this.PasswordGenerationFallbackMinLength + FallbackRequireUpper = $this.PasswordGenerationRequireUpper + FallbackRequireLower = $this.PasswordGenerationRequireLower + FallbackRequireDigit = $this.PasswordGenerationRequireDigit + FallbackRequireSpecial = $this.PasswordGenerationRequireSpecial + FallbackSpecialCharSet = $this.PasswordGenerationSpecialCharSet + } + + if ($null -ne $this.Credential) { + $passwordGenParams['Credential'] = $this.Credential + } + + $generatedPasswordInfo = New-IdleADPassword @passwordGenParams + $params['AccountPassword'] = $generatedPasswordInfo.SecureString + + Write-Verbose "AD Provider: Generated password using $($generatedPasswordInfo.UsedPolicy) policy (MinLength=$($generatedPasswordInfo.MinLength))" + } + + # Handle ResetOnFirstLogin (ChangePasswordAtLogon) + # Default to true when a password was set or generated, unless explicitly overridden + $resetOnFirstLogin = $true + if ($effectiveAttributes.ContainsKey('ResetOnFirstLogin')) { + $resetOnFirstLogin = [bool]$effectiveAttributes['ResetOnFirstLogin'] + } + + # Only set ChangePasswordAtLogon if a password was provided or generated + if ($hasAccountPassword -or $hasAccountPasswordAsPlainText -or ($null -ne $generatedPasswordInfo)) { + $params['ChangePasswordAtLogon'] = $resetOnFirstLogin + } # Handle OtherAttributes for custom LDAP attributes if ($effectiveAttributes.ContainsKey('OtherAttributes')) { @@ -418,6 +495,13 @@ function New-IdleADAdapter { } $user = New-ADUser @params -PassThru + + # Return user with optional password generation info + if ($null -ne $generatedPasswordInfo) { + # Attach password generation info to user object for caller to access + $user | Add-Member -MemberType NoteProperty -Name '_GeneratedPasswordInfo' -Value $generatedPasswordInfo -Force + } + return $user } -Force diff --git a/src/IdLE.Provider.AD/Private/New-IdleADPassword.ps1 b/src/IdLE.Provider.AD/Private/New-IdleADPassword.ps1 new file mode 100644 index 00000000..e661c35c --- /dev/null +++ b/src/IdLE.Provider.AD/Private/New-IdleADPassword.ps1 @@ -0,0 +1,215 @@ +function New-IdleADPassword { + <# + .SYNOPSIS + Generates a policy-compliant Active Directory password. + + .DESCRIPTION + Generates a password that satisfies Active Directory domain password policy requirements. + First attempts to read the domain password policy using Get-ADDefaultDomainPasswordPolicy. + If policy reading fails, falls back to provider-specified configuration. + + The generated password will meet: + - Minimum length (from policy or fallback configuration) + - Complexity requirements (uppercase, lowercase, digits, special characters) + + .PARAMETER Credential + Optional PSCredential for accessing Active Directory. If not provided, uses integrated auth. + + .PARAMETER FallbackMinLength + Fallback minimum password length if domain policy cannot be read. Default is 24. + + .PARAMETER FallbackRequireUpper + Fallback requirement for uppercase characters. Default is $true. + + .PARAMETER FallbackRequireLower + Fallback requirement for lowercase characters. Default is $true. + + .PARAMETER FallbackRequireDigit + Fallback requirement for digit characters. Default is $true. + + .PARAMETER FallbackRequireSpecial + Fallback requirement for special characters. Default is $true. + + .PARAMETER FallbackSpecialCharSet + Set of special characters to use when generating passwords. Default is '!@#$%&*+-_=?'. + + .OUTPUTS + PSCustomObject with properties: + - PlainText: The generated password as a plain string + - SecureString: The generated password as a SecureString + - ProtectedString: The generated password as a ProtectedString (DPAPI-scoped) + - UsedPolicy: Information about which policy was used (domain or fallback) + + .EXAMPLE + $result = New-IdleADPassword + # Uses domain policy if available, otherwise uses default fallback configuration + + .EXAMPLE + $result = New-IdleADPassword -FallbackMinLength 32 -FallbackRequireSpecial $true + # Uses custom fallback configuration if domain policy cannot be read + #> + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'This function generates passwords and must convert the generated plaintext to SecureString')] + param( + [Parameter()] + [AllowNull()] + [PSCredential] $Credential, + + [Parameter()] + [int] $FallbackMinLength = 24, + + [Parameter()] + [bool] $FallbackRequireUpper = $true, + + [Parameter()] + [bool] $FallbackRequireLower = $true, + + [Parameter()] + [bool] $FallbackRequireDigit = $true, + + [Parameter()] + [bool] $FallbackRequireSpecial = $true, + + [Parameter()] + [string] $FallbackSpecialCharSet = '!@#$%&*+-_=?' + ) + + # Character sets for password generation + $upperChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + $lowerChars = 'abcdefghijklmnopqrstuvwxyz' + $digitChars = '0123456789' + + # Try to read domain password policy + $policySource = 'Fallback' + $minLength = $FallbackMinLength + $requireUpper = $FallbackRequireUpper + $requireLower = $FallbackRequireLower + $requireDigit = $FallbackRequireDigit + $requireSpecial = $FallbackRequireSpecial + $specialCharSet = $FallbackSpecialCharSet + + # Validate special character set if special characters are required + if ($requireSpecial -and ([string]::IsNullOrEmpty($specialCharSet) -or $specialCharSet.Length -eq 0)) { + # Fall back to a safe default character set if none provided + $specialCharSet = '!@#$%&*+-_=?' + Write-Verbose "AD Password Generation: Special characters required but FallbackSpecialCharSet is empty. Using default: '$specialCharSet'" + } + + try { + $params = @{ + ErrorAction = 'Stop' + } + if ($null -ne $Credential) { + $params['Credential'] = $Credential + } + + $domainPolicy = Get-ADDefaultDomainPasswordPolicy @params + + if ($null -ne $domainPolicy) { + $policySource = 'DomainPolicy' + + # Use domain policy minimum length, but enforce fallback as minimum baseline + # This ensures generated passwords meet at least the provider's configured minimum, + # even if domain policy allows shorter passwords (defense in depth) + if ($domainPolicy.MinPasswordLength -gt 0) { + $minLength = [Math]::Max($domainPolicy.MinPasswordLength, $FallbackMinLength) + } + + # If complexity is enabled, require all character classes + if ($domainPolicy.ComplexityEnabled) { + $requireUpper = $true + $requireLower = $true + $requireDigit = $true + $requireSpecial = $true + } + + Write-Verbose "AD Password Generation: Using domain policy (MinLength=$minLength, ComplexityEnabled=$($domainPolicy.ComplexityEnabled))" + } + } + catch { + Write-Verbose "AD Password Generation: Failed to read domain policy, using fallback configuration: $_" + } + + # Ensure minimum length is at least 8 (AD minimum) + if ($minLength -lt 8) { + $minLength = 8 + Write-Verbose "AD Password Generation: Adjusted minimum length to 8 (AD minimum)" + } + + # Build character pool based on requirements + $charPool = '' + $requiredChars = @() + + if ($requireUpper) { + $charPool += $upperChars + # Pick one random uppercase character to guarantee requirement + $requiredChars += $upperChars[(Get-Random -Minimum 0 -Maximum $upperChars.Length)] + } + + if ($requireLower) { + $charPool += $lowerChars + # Pick one random lowercase character to guarantee requirement + $requiredChars += $lowerChars[(Get-Random -Minimum 0 -Maximum $lowerChars.Length)] + } + + if ($requireDigit) { + $charPool += $digitChars + # Pick one random digit to guarantee requirement + $requiredChars += $digitChars[(Get-Random -Minimum 0 -Maximum $digitChars.Length)] + } + + if ($requireSpecial) { + $charPool += $specialCharSet + # Pick one random special character to guarantee requirement + $requiredChars += $specialCharSet[(Get-Random -Minimum 0 -Maximum $specialCharSet.Length)] + } + + # If no requirements specified, use all character types + if ($charPool.Length -eq 0) { + $charPool = $upperChars + $lowerChars + $digitChars + $specialCharSet + } + + # Calculate remaining length after required characters + $remainingLength = $minLength - $requiredChars.Count + + # Generate remaining random characters using a cryptographically secure RNG + $randomChars = @() + for ($i = 0; $i -lt $remainingLength; $i++) { + $index = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32(0, $charPool.Length) + $randomChars += $charPool[$index] + } + + # Combine required and random characters + $allChars = $requiredChars + $randomChars + + # Shuffle the characters to randomize position of required characters (Fisher–Yates, CSPRNG-driven) + for ($i = $allChars.Count - 1; $i -gt 0; $i--) { + $j = [System.Security.Cryptography.RandomNumberGenerator]::GetInt32(0, $i + 1) + $temp = $allChars[$i] + $allChars[$i] = $allChars[$j] + $allChars[$j] = $temp + } + $shuffledChars = $allChars + + # Join to create the password + $plainTextPassword = -join $shuffledChars + + # Convert to SecureString + $securePassword = ConvertTo-SecureString -String $plainTextPassword -AsPlainText -Force + + # Convert to ProtectedString (DPAPI-scoped) + $protectedPassword = ConvertFrom-SecureString -SecureString $securePassword + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ADPassword' + PlainText = $plainTextPassword + SecureString = $securePassword + ProtectedString = $protectedPassword + UsedPolicy = $policySource + MinLength = $minLength + RequiredUpper = $requireUpper + RequiredLower = $requireLower + RequiredDigit = $requireDigit + RequiredSpecial = $requireSpecial + } +} diff --git a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 index 5cabcc34..56636d7a 100644 --- a/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 +++ b/src/IdLE.Provider.AD/Public/New-IdleADIdentityProvider.ps1 @@ -29,6 +29,24 @@ function New-IdleADIdentityProvider { When $true, the provider advertises the Delete capability and allows identity deletion. Default is $false for safety. + .PARAMETER PasswordGenerationFallbackMinLength + Fallback minimum password length when domain policy cannot be read. Default is 24. + + .PARAMETER PasswordGenerationRequireUpper + Fallback requirement for uppercase characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationRequireLower + Fallback requirement for lowercase characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationRequireDigit + Fallback requirement for digit characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationRequireSpecial + Fallback requirement for special characters in generated passwords. Default is $true. + + .PARAMETER PasswordGenerationSpecialCharSet + Set of special characters to use in generated passwords. Default is '!@#$%&*+-_=?'. + .PARAMETER Adapter Internal parameter for dependency injection during testing. Allows unit tests to inject a fake AD adapter without requiring a real Active Directory environment. @@ -40,6 +58,13 @@ function New-IdleADIdentityProvider { Identity = $provider } + .EXAMPLE + # Custom password generation fallback configuration + $provider = New-IdleADIdentityProvider -PasswordGenerationFallbackMinLength 32 -PasswordGenerationSpecialCharSet '!@#$%^&*()' + $plan = New-IdlePlan -WorkflowPath './workflow.psd1' -Request $request -Providers @{ + Identity = $provider + } + .EXAMPLE # Multi-role scenario with New-IdleAuthSessionBroker (recommended) $tier0Credential = Get-Credential -Message "Enter Tier0 admin credentials" @@ -82,6 +107,24 @@ function New-IdleADIdentityProvider { [Parameter()] [switch] $AllowDelete, + [Parameter()] + [int] $PasswordGenerationFallbackMinLength = 24, + + [Parameter()] + [bool] $PasswordGenerationRequireUpper = $true, + + [Parameter()] + [bool] $PasswordGenerationRequireLower = $true, + + [Parameter()] + [bool] $PasswordGenerationRequireDigit = $true, + + [Parameter()] + [bool] $PasswordGenerationRequireSpecial = $true, + + [Parameter()] + [string] $PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?', + [Parameter()] [AllowNull()] [object] $Adapter @@ -99,7 +142,12 @@ function New-IdleADIdentityProvider { } if ($null -eq $Adapter) { - $Adapter = New-IdleADAdapter + $Adapter = New-IdleADAdapter -PasswordGenerationFallbackMinLength $PasswordGenerationFallbackMinLength ` + -PasswordGenerationRequireUpper $PasswordGenerationRequireUpper ` + -PasswordGenerationRequireLower $PasswordGenerationRequireLower ` + -PasswordGenerationRequireDigit $PasswordGenerationRequireDigit ` + -PasswordGenerationRequireSpecial $PasswordGenerationRequireSpecial ` + -PasswordGenerationSpecialCharSet $PasswordGenerationSpecialCharSet } $convertToEntitlement = { @@ -208,6 +256,12 @@ function New-IdleADIdentityProvider { Name = 'ADIdentityProvider' Adapter = $Adapter AllowDelete = [bool]$AllowDelete + PasswordGenerationFallbackMinLength = $PasswordGenerationFallbackMinLength + PasswordGenerationRequireUpper = $PasswordGenerationRequireUpper + PasswordGenerationRequireLower = $PasswordGenerationRequireLower + PasswordGenerationRequireDigit = $PasswordGenerationRequireDigit + PasswordGenerationRequireSpecial = $PasswordGenerationRequireSpecial + PasswordGenerationSpecialCharSet = $PasswordGenerationSpecialCharSet } # Helper method to extract credential from AuthSession and create effective adapter @@ -257,7 +311,13 @@ function New-IdleADIdentityProvider { } throw $errorMsg } - return New-IdleADAdapter -Credential $credential + return New-IdleADAdapter -Credential $credential ` + -PasswordGenerationFallbackMinLength $this.PasswordGenerationFallbackMinLength ` + -PasswordGenerationRequireUpper $this.PasswordGenerationRequireUpper ` + -PasswordGenerationRequireLower $this.PasswordGenerationRequireLower ` + -PasswordGenerationRequireDigit $this.PasswordGenerationRequireDigit ` + -PasswordGenerationRequireSpecial $this.PasswordGenerationRequireSpecial ` + -PasswordGenerationSpecialCharSet $this.PasswordGenerationSpecialCharSet } return $this.Adapter @@ -387,7 +447,13 @@ function New-IdleADIdentityProvider { $enabled = [bool]$Attributes['Enabled'] } - $null = $adapter.NewUser($IdentityKey, $Attributes, $enabled) + # Extract AllowPlainTextPasswordOutput before passing to adapter + $allowPlainTextOutput = $false + if ($Attributes.ContainsKey('AllowPlainTextPasswordOutput')) { + $allowPlainTextOutput = [bool]$Attributes['AllowPlainTextPasswordOutput'] + } + + $user = $adapter.NewUser($IdentityKey, $Attributes, $enabled) # Emit observability event if ($this.PSObject.Properties.Name -contains 'EventSink' -and $null -ne $this.EventSink) { @@ -398,12 +464,35 @@ function New-IdleADIdentityProvider { $this.EventSink.WriteEvent('Provider.AD.CreateIdentity.AttributesRequested', 'Attributes requested during identity creation', 'CreateIdentity', $eventData) } - return [pscustomobject]@{ + # Build result with optional password generation info + $result = [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' IdentityKey = $IdentityKey Changed = $true } + + # Handle password generation output (if password was generated) + if ($null -ne $user.PSObject.Properties['_GeneratedPasswordInfo']) { + $passwordInfo = $user._GeneratedPasswordInfo + + # Always include ProtectedString for reveal path (DPAPI-scoped) + $result | Add-Member -MemberType NoteProperty -Name 'GeneratedAccountPasswordProtected' -Value $passwordInfo.ProtectedString + + if ($allowPlainTextOutput) { + # Include plaintext password only when explicitly requested + $result | Add-Member -MemberType NoteProperty -Name 'GeneratedAccountPasswordPlainText' -Value $passwordInfo.PlainText + Write-Verbose "AD Provider: Plaintext password output enabled (AllowPlainTextPasswordOutput=true)" + } + + # Add metadata about password generation + $result | Add-Member -MemberType NoteProperty -Name 'PasswordGenerated' -Value $true + $result | Add-Member -MemberType NoteProperty -Name 'PasswordGenerationPolicyUsed' -Value $passwordInfo.UsedPolicy + + Write-Verbose "AD Provider: Password was generated using $($passwordInfo.UsedPolicy) policy" + } + + return $result } -Force $provider | Add-Member -MemberType ScriptMethod -Name DeleteIdentity -Value { diff --git a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 index d801b5fc..43c3d2dc 100644 --- a/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 +++ b/src/IdLE.Provider.EntraID/Public/New-IdleEntraIDIdentityProvider.ps1 @@ -87,6 +87,7 @@ function New-IdleEntraIDIdentityProvider { See docs/reference/providers/provider-entraID.md for detailed permission requirements. #> [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Used only for generated passwords that need to be returned to caller; conversion is necessary for ProtectedString output')] param( [Parameter()] [switch] $AllowDelete, @@ -468,15 +469,29 @@ function New-IdleEntraIDIdentityProvider { } # Password policy for new users + $generatedPassword = $null + $passwordWasGenerated = $false + if ($Attributes.ContainsKey('PasswordProfile')) { $payload['passwordProfile'] = $Attributes['PasswordProfile'] } else { + # Generate initial password (GUID format - compliant with Entra ID requirements) + $generatedPassword = [System.Guid]::NewGuid().ToString() + $passwordWasGenerated = $true + # Default: force change on first sign-in + $forceChange = $true + if ($Attributes.ContainsKey('ForceChangePasswordNextSignIn')) { + $forceChange = [bool]$Attributes['ForceChangePasswordNextSignIn'] + } + $payload['passwordProfile'] = @{ - forceChangePasswordNextSignIn = $true - password = [System.Guid]::NewGuid().ToString() + forceChangePasswordNextSignIn = $forceChange + password = $generatedPassword } + + Write-Verbose "Entra ID Provider: Generated initial password (GUID format)" } # Optional attributes @@ -494,12 +509,43 @@ function New-IdleEntraIDIdentityProvider { $this.Adapter.CreateUser($payload, $accessToken) | Out-Null - return [pscustomobject]@{ + # Build result with optional password generation info + $result = [pscustomobject]@{ PSTypeName = 'IdLE.ProviderResult' Operation = 'CreateIdentity' IdentityKey = $IdentityKey Changed = $true } + + # Handle password generation output (if password was generated) + if ($passwordWasGenerated -and $null -ne $generatedPassword) { + # Convert to SecureString and ProtectedString for controlled output + $securePassword = ConvertTo-SecureString -String $generatedPassword -AsPlainText -Force + $protectedPassword = ConvertFrom-SecureString -SecureString $securePassword + + # Always include ProtectedString for reveal path (DPAPI-scoped) + $result | Add-Member -MemberType NoteProperty -Name 'GeneratedAccountPasswordProtected' -Value $protectedPassword + + # Check for explicit opt-in to plaintext output + $allowPlainTextOutput = $false + if ($Attributes.ContainsKey('AllowPlainTextPasswordOutput')) { + $allowPlainTextOutput = [bool]$Attributes['AllowPlainTextPasswordOutput'] + } + + if ($allowPlainTextOutput) { + # Include plaintext password only when explicitly requested + $result | Add-Member -MemberType NoteProperty -Name 'GeneratedAccountPasswordPlainText' -Value $generatedPassword + Write-Verbose "Entra ID Provider: Plaintext password output enabled (AllowPlainTextPasswordOutput=true)" + } + + # Add metadata about password generation + $result | Add-Member -MemberType NoteProperty -Name 'PasswordGenerated' -Value $true + $result | Add-Member -MemberType NoteProperty -Name 'PasswordGenerationMethod' -Value 'GUID' + + Write-Verbose "Entra ID Provider: Password was generated using GUID method" + } + + return $result } -Force $provider | Add-Member -MemberType ScriptMethod -Name DeleteIdentity -Value { diff --git a/tests/Core/Copy-IdleRedactedObject.Tests.ps1 b/tests/Core/Copy-IdleRedactedObject.Tests.ps1 index fa19ebab..c1d7e24c 100644 --- a/tests/Core/Copy-IdleRedactedObject.Tests.ps1 +++ b/tests/Core/Copy-IdleRedactedObject.Tests.ps1 @@ -160,5 +160,57 @@ Describe 'Copy-IdleRedactedObject - deterministic redaction utility' { $copy.settings.AccountPasswordAsPlainText | Should -Be '[REDACTED]' $copy.settings.enabled | Should -Be $true } + + It 'redacts GeneratedAccountPasswordPlainText key' { + $input = @{ + userName = 'testuser' + GeneratedAccountPasswordPlainText = 'GeneratedPlainTextPassword123!' + otherField = 'visible' + } + + $copy = Copy-IdleRedactedObject -Value $input + + $copy.userName | Should -Be 'testuser' + $copy.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' + $copy.otherField | Should -Be 'visible' + } + + It 'redacts GeneratedAccountPasswordProtected key' { + $input = @{ + userName = 'testuser' + GeneratedAccountPasswordProtected = '76492d1116743f0423413b16050a5345MgB8AHcAYwBVAG0AawBlAEoAZgBMAGIARABlAEIASQBvAA==' + otherField = 'visible' + } + + $copy = Copy-IdleRedactedObject -Value $input + + $copy.userName | Should -Be 'testuser' + $copy.GeneratedAccountPasswordProtected | Should -Be '[REDACTED]' + $copy.otherField | Should -Be 'visible' + } + + It 'redacts generated password fields in nested structures' { + $input = @{ + result = @{ + IdentityKey = 'user@contoso.com' + GeneratedAccountPasswordPlainText = 'PlainPassword' + GeneratedAccountPasswordProtected = 'ProtectedPassword' + Changed = $true + } + metadata = [pscustomobject]@{ + GeneratedAccountPasswordPlainText = 'AnotherPlain' + timestamp = '2024-01-01' + } + } + + $copy = Copy-IdleRedactedObject -Value $input + + $copy.result.IdentityKey | Should -Be 'user@contoso.com' + $copy.result.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' + $copy.result.GeneratedAccountPasswordProtected | Should -Be '[REDACTED]' + $copy.result.Changed | Should -Be $true + $copy.metadata.GeneratedAccountPasswordPlainText | Should -Be '[REDACTED]' + $copy.metadata.timestamp | Should -Be '2024-01-01' + } } } diff --git a/tests/Providers/ADIdentityProvider.Tests.ps1 b/tests/Providers/ADIdentityProvider.Tests.ps1 index b197fb1b..127eacdd 100644 --- a/tests/Providers/ADIdentityProvider.Tests.ps1 +++ b/tests/Providers/ADIdentityProvider.Tests.ps1 @@ -46,6 +46,12 @@ Describe 'AD identity provider' { $adapter = [pscustomobject]@{ PSTypeName = 'FakeADAdapter' Store = $store + PasswordGenerationFallbackMinLength = 24 + PasswordGenerationRequireUpper = $true + PasswordGenerationRequireLower = $true + PasswordGenerationRequireDigit = $true + PasswordGenerationRequireSpecial = $true + PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?' } # Add Manager DN validation helper (matching real adapter) @@ -229,6 +235,7 @@ Describe 'AD identity provider' { # Password handling validation (same as real adapter) $hasAccountPassword = $Attributes.ContainsKey('AccountPassword') $hasAccountPasswordAsPlainText = $Attributes.ContainsKey('AccountPasswordAsPlainText') + $generatedPasswordInfo = $null if ($hasAccountPassword -and $hasAccountPasswordAsPlainText) { throw "Ambiguous password configuration: both 'AccountPassword' and 'AccountPasswordAsPlainText' are provided. Use only one." @@ -260,8 +267,7 @@ Describe 'AD identity provider' { throw "AccountPassword: Expected a SecureString or ProtectedString (string from ConvertFrom-SecureString), but received type: $($passwordValue.GetType().FullName)" } } - - if ($hasAccountPasswordAsPlainText) { + elseif ($hasAccountPasswordAsPlainText) { $plainTextPassword = $Attributes['AccountPasswordAsPlainText'] if ($null -eq $plainTextPassword) { @@ -276,6 +282,25 @@ Describe 'AD identity provider' { throw "AccountPasswordAsPlainText: Password cannot be null or empty." } } + elseif ($Enabled) { + # Simulate password generation when enabled and no password provided + $fakePassword = 'FakeGenerated123!@#' + $securePassword = ConvertTo-SecureString -String $fakePassword -AsPlainText -Force + $protectedPassword = ConvertFrom-SecureString -SecureString $securePassword + + $generatedPasswordInfo = [pscustomobject]@{ + PSTypeName = 'IdLE.ADPassword' + PlainText = $fakePassword + SecureString = $securePassword + ProtectedString = $protectedPassword + UsedPolicy = 'Fallback' + MinLength = 24 + RequiredUpper = $true + RequiredLower = $true + RequiredDigit = $true + RequiredSpecial = $true + } + } # Manager resolution (same as real adapter) $resolvedManager = $null @@ -305,6 +330,11 @@ Describe 'AD identity provider' { Groups = @() } + # Attach password generation info if generated + if ($null -ne $generatedPasswordInfo) { + $user | Add-Member -MemberType NoteProperty -Name '_GeneratedPasswordInfo' -Value $generatedPasswordInfo -Force + } + $this.Store[$newGuid] = $user return $user } -Force @@ -1654,4 +1684,278 @@ Describe 'AD identity provider' { Should -Throw -ExpectedMessage '*Unsupported attribute*' } } + + Context 'Password generation and controlled output' { + BeforeAll { + # Create a fake adapter WITHOUT auto-creation behavior for password generation tests + # Auto-creation breaks these tests because CreateIdentity checks if user exists first + $store = @{} + + $adapter = [pscustomobject]@{ + PSTypeName = 'FakeADAdapter' + Store = $store + PasswordGenerationFallbackMinLength = 24 + PasswordGenerationRequireUpper = $true + PasswordGenerationRequireLower = $true + PasswordGenerationRequireDigit = $true + PasswordGenerationRequireSpecial = $true + PasswordGenerationSpecialCharSet = '!@#$%&*+-_=?' + } + + # Add lookup methods WITHOUT auto-creation + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param([string]$Upn) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].UserPrincipalName -eq $Upn) { + return $this.Store[$key] + } + } + return $null + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserBySam -Value { + param([string]$SamAccountName) + foreach ($key in $this.Store.Keys) { + if ($this.Store[$key].sAMAccountName -eq $SamAccountName) { + return $this.Store[$key] + } + } + return $null + } -Force + + $adapter | Add-Member -MemberType ScriptMethod -Name GetUserByGuid -Value { + param([string]$Guid) + if ($this.Store.ContainsKey($Guid)) { + return $this.Store[$Guid] + } + return $null + } -Force + + # Add NewUser method with password generation support + $adapter | Add-Member -MemberType ScriptMethod -Name NewUser -Value { + param([string]$IdentityKey, [hashtable]$Attributes, [bool]$Enabled) + + $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 + $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 with generation support + $hasAccountPassword = $Attributes.ContainsKey('AccountPassword') + $hasAccountPasswordAsPlainText = $Attributes.ContainsKey('AccountPasswordAsPlainText') + $generatedPasswordInfo = $null + + if ($hasAccountPassword -and $hasAccountPasswordAsPlainText) { + throw "Ambiguous password configuration: both 'AccountPassword' and 'AccountPasswordAsPlainText' are provided. Use only one." + } + + if (-not $hasAccountPassword -and -not $hasAccountPasswordAsPlainText -and $Enabled) { + # Simulate password generation + $fakePassword = 'FakeGenerated123!@#' + $securePassword = ConvertTo-SecureString -String $fakePassword -AsPlainText -Force + $protectedPassword = ConvertFrom-SecureString -SecureString $securePassword + + $generatedPasswordInfo = [pscustomobject]@{ + PSTypeName = 'IdLE.ADPassword' + PlainText = $fakePassword + SecureString = $securePassword + ProtectedString = $protectedPassword + UsedPolicy = 'Fallback' + MinLength = 24 + RequiredUpper = $true + RequiredLower = $true + RequiredDigit = $true + RequiredSpecial = $true + } + } + + $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]$newGuid + sAMAccountName = $sam + UserPrincipalName = $upn + DistinguishedName = "CN=$derivedName,$path" + Enabled = $Enabled + GivenName = $Attributes['GivenName'] + Surname = $Attributes['Surname'] + DisplayName = $Attributes['DisplayName'] + Description = $Attributes['Description'] + Department = $Attributes['Department'] + Title = $Attributes['Title'] + EmailAddress = $Attributes['EmailAddress'] + Manager = $null + Groups = @() + } + + # Attach password generation info if generated + if ($null -ne $generatedPasswordInfo) { + $user | Add-Member -MemberType NoteProperty -Name '_GeneratedPasswordInfo' -Value $generatedPasswordInfo -Force + } + + $this.Store[$newGuid] = $user + return $user + } -Force + + $provider = New-IdleADIdentityProvider -Adapter $adapter + $script:PasswordTestProvider = $provider + $script:PasswordTestAdapter = $adapter + } + + It 'Generates password when enabled and no password provided' { + $attrs = @{ + SamAccountName = 'pwdtest1' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest1', $attrs) + + # Verify password was generated + $result.PasswordGenerated | Should -BeTrue + $result.GeneratedAccountPasswordProtected | Should -Not -BeNullOrEmpty + } + + It 'Does not generate password when disabled and no password provided' { + $attrs = @{ + SamAccountName = 'pwdtest2' + GivenName = 'Test' + Surname = 'User' + Enabled = $false + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest2', $attrs) + + # Verify password was not generated + $result.PSObject.Properties.Name | Should -Not -Contain 'PasswordGenerated' + $result.PSObject.Properties.Name | Should -Not -Contain 'GeneratedAccountPasswordProtected' + } + + It 'Does not include plaintext password by default' { + $attrs = @{ + SamAccountName = 'pwdtest3' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest3', $attrs) + + # Verify plaintext password is not included + $result.PSObject.Properties.Name | Should -Not -Contain 'GeneratedAccountPasswordPlainText' + } + + It 'Includes plaintext password when AllowPlainTextPasswordOutput is true' { + $attrs = @{ + SamAccountName = 'pwdtest4' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + AllowPlainTextPasswordOutput = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest4', $attrs) + + # Verify plaintext password is included + $result.GeneratedAccountPasswordPlainText | Should -Not -BeNullOrEmpty + $result.GeneratedAccountPasswordPlainText | Should -BeOfType [string] + } + + It 'Does not generate password when AccountPassword is provided' { + $securePassword = ConvertTo-SecureString -String 'ExplicitPassword123!' -AsPlainText -Force + $attrs = @{ + SamAccountName = 'pwdtest5' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + AccountPassword = $securePassword + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest5', $attrs) + + # Verify password was not generated (explicit password provided) + $result.PSObject.Properties.Name | Should -Not -Contain 'PasswordGenerated' + } + + It 'Does not generate password when AccountPasswordAsPlainText is provided' { + $attrs = @{ + SamAccountName = 'pwdtest6' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + AccountPasswordAsPlainText = 'ExplicitPassword123!' + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest6', $attrs) + + # Verify password was not generated (explicit password provided) + $result.PSObject.Properties.Name | Should -Not -Contain 'PasswordGenerated' + } + + It 'Includes policy information when password is generated' { + $attrs = @{ + SamAccountName = 'pwdtest7' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest7', $attrs) + + # Verify policy information is included + # Note: Fake adapter always uses 'Fallback' policy; real adapter would use 'DomainPolicy' when available + $result.PasswordGenerationPolicyUsed | Should -Not -BeNullOrEmpty + $result.PasswordGenerationPolicyUsed | Should -Be 'Fallback' + } + + It 'Accepts ResetOnFirstLogin attribute' { + $attrs = @{ + SamAccountName = 'pwdtest8' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + ResetOnFirstLogin = $false + } + + # Should not throw when ResetOnFirstLogin is provided + { $script:PasswordTestProvider.CreateIdentity('pwdtest8', $attrs) } | Should -Not -Throw + } + + It 'Generated password can be revealed using ProtectedString' { + $attrs = @{ + SamAccountName = 'pwdtest9' + GivenName = 'Test' + Surname = 'User' + Enabled = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('pwdtest9', $attrs) + + # Verify ProtectedString can be converted back to SecureString + $protectedString = $result.GeneratedAccountPasswordProtected + { ConvertTo-SecureString -String $protectedString } | Should -Not -Throw + + # Verify conversion works + $secure = ConvertTo-SecureString -String $protectedString + $secure | Should -BeOfType [securestring] + } + } } diff --git a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 index f2896391..a63c9fd6 100644 --- a/tests/Providers/EntraIDIdentityProvider.Tests.ps1 +++ b/tests/Providers/EntraIDIdentityProvider.Tests.ps1 @@ -941,3 +941,163 @@ Describe 'EntraID identity provider - RevokeSessions' { $result.Operation | Should -Be 'RevokeSessions' } } + +Describe 'EntraID identity provider - Password generation' { + BeforeAll { + # Create a fake adapter for password generation tests + $fakeAdapter = [pscustomobject]@{ + PSTypeName = 'IdLE.EntraIDAdapter.Fake' + Store = @{} + LastCreatePayload = $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserById -Value { + param($ObjectId, $AccessToken) + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByUpn -Value { + param($Upn, $AccessToken) + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name GetUserByMail -Value { + param($Mail, $AccessToken) + return $null + } + + $fakeAdapter | Add-Member -MemberType ScriptMethod -Name CreateUser -Value { + param($Payload, $AccessToken) + + # Store the payload for inspection + $this.LastCreatePayload = $Payload + + $id = [guid]::NewGuid().ToString() + return @{ + id = $id + userPrincipalName = $Payload.userPrincipalName + displayName = $Payload.displayName + accountEnabled = $Payload.accountEnabled + } + } -Force + + $provider = New-IdleEntraIDIdentityProvider -Adapter $fakeAdapter + $script:PasswordTestProvider = $provider + $script:PasswordTestAdapter = $fakeAdapter + } + + It 'Generates password when no PasswordProfile is provided' { + $attrs = @{ + UserPrincipalName = 'newuser@contoso.com' + DisplayName = 'New User' + } + + $result = $script:PasswordTestProvider.CreateIdentity('newuser@contoso.com', $attrs, 'fake-token') + + # Verify password was generated + $result.PasswordGenerated | Should -BeTrue + $result.GeneratedAccountPasswordProtected | Should -Not -BeNullOrEmpty + $result.PasswordGenerationMethod | Should -Be 'GUID' + } + + It 'Does not include plaintext password by default' { + $attrs = @{ + UserPrincipalName = 'user@contoso.com' + DisplayName = 'User' + } + + $result = $script:PasswordTestProvider.CreateIdentity('user@contoso.com', $attrs, 'fake-token') + + # Verify plaintext password is not included + $result.PSObject.Properties.Name | Should -Not -Contain 'GeneratedAccountPasswordPlainText' + } + + It 'Includes plaintext password when AllowPlainTextPasswordOutput is true' { + $attrs = @{ + UserPrincipalName = 'user2@contoso.com' + DisplayName = 'User 2' + AllowPlainTextPasswordOutput = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('user2@contoso.com', $attrs, 'fake-token') + + # Verify plaintext password is included + $result.GeneratedAccountPasswordPlainText | Should -Not -BeNullOrEmpty + $result.GeneratedAccountPasswordPlainText | Should -BeOfType [string] + + # Verify it's a GUID format + { [guid]::Parse($result.GeneratedAccountPasswordPlainText) } | Should -Not -Throw + } + + It 'Does not generate password when PasswordProfile is provided' { + $attrs = @{ + UserPrincipalName = 'user3@contoso.com' + DisplayName = 'User 3' + PasswordProfile = @{ + password = 'Explicit@Pass123!' + forceChangePasswordNextSignIn = $true + } + } + + $result = $script:PasswordTestProvider.CreateIdentity('user3@contoso.com', $attrs, 'fake-token') + + # Verify password was not generated (explicit password provided) + $result.PSObject.Properties.Name | Should -Not -Contain 'PasswordGenerated' + } + + It 'Sets forceChangePasswordNextSignIn to true by default' { + $attrs = @{ + UserPrincipalName = 'user4@contoso.com' + DisplayName = 'User 4' + } + + $result = $script:PasswordTestProvider.CreateIdentity('user4@contoso.com', $attrs, 'fake-token') + + # Verify the payload sent to adapter + $script:PasswordTestAdapter.LastCreatePayload.passwordProfile.forceChangePasswordNextSignIn | Should -BeTrue + } + + It 'Allows ForceChangePasswordNextSignIn to be set to false' { + $attrs = @{ + UserPrincipalName = 'serviceaccount@contoso.com' + DisplayName = 'Service Account' + ForceChangePasswordNextSignIn = $false + } + + $result = $script:PasswordTestProvider.CreateIdentity('serviceaccount@contoso.com', $attrs, 'fake-token') + + # Verify the payload sent to adapter + $script:PasswordTestAdapter.LastCreatePayload.passwordProfile.forceChangePasswordNextSignIn | Should -BeFalse + } + + It 'Generated password can be revealed using ProtectedString' { + $attrs = @{ + UserPrincipalName = 'user5@contoso.com' + DisplayName = 'User 5' + } + + $result = $script:PasswordTestProvider.CreateIdentity('user5@contoso.com', $attrs, 'fake-token') + + # Verify ProtectedString can be converted back to SecureString + $protectedString = $result.GeneratedAccountPasswordProtected + { ConvertTo-SecureString -String $protectedString } | Should -Not -Throw + + # Verify conversion works + $secure = ConvertTo-SecureString -String $protectedString + $secure | Should -BeOfType [securestring] + } + + It 'Generated password is a valid GUID' { + $attrs = @{ + UserPrincipalName = 'user6@contoso.com' + DisplayName = 'User 6' + AllowPlainTextPasswordOutput = $true + } + + $result = $script:PasswordTestProvider.CreateIdentity('user6@contoso.com', $attrs, 'fake-token') + + # Verify the generated password is a valid GUID + $plainPwd = $result.GeneratedAccountPasswordPlainText + { [guid]::Parse($plainPwd) } | Should -Not -Throw + } +} diff --git a/tests/Providers/New-IdleADPassword.Tests.ps1 b/tests/Providers/New-IdleADPassword.Tests.ps1 new file mode 100644 index 00000000..8eed99d0 --- /dev/null +++ b/tests/Providers/New-IdleADPassword.Tests.ps1 @@ -0,0 +1,294 @@ +BeforeDiscovery { + . (Join-Path (Split-Path -Path $PSScriptRoot -Parent) '_testHelpers.ps1') + Import-IdleTestModule + + $testsRoot = Split-Path -Path $PSScriptRoot -Parent + $repoRoot = Split-Path -Path $testsRoot -Parent + + # Import AD provider module to access private functions + $adModulePath = Join-Path -Path $repoRoot -ChildPath 'src\IdLE.Provider.AD\IdLE.Provider.AD.psm1' + if (-not (Test-Path -LiteralPath $adModulePath -PathType Leaf)) { + throw "AD provider module not found at: $adModulePath" + } + Import-Module $adModulePath -Force +} + +Describe 'New-IdleADPassword - Policy-aware password generation' { + InModuleScope 'IdLE.Provider.AD' { + BeforeAll { + # Ensure the function is available + Get-Command New-IdleADPassword -ErrorAction Stop | Out-Null + + # Mock Get-ADDefaultDomainPasswordPolicy globally for this module scope + # This cmdlet is from the ActiveDirectory module which may not be available in test environment + if (-not (Get-Command Get-ADDefaultDomainPasswordPolicy -ErrorAction SilentlyContinue)) { + function Get-ADDefaultDomainPasswordPolicy { + param( + [Parameter()] + [pscredential]$Credential, + [Parameter()] + [string]$ErrorAction + ) + throw "Not implemented - should be mocked in tests" + } + } + } + + Context 'Password generation with fallback configuration' { + It 'Generates password with default fallback settings when policy cannot be read' { + # Mock Get-ADDefaultDomainPasswordPolicy to simulate failure + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword + + # Verify password was generated + $result.PlainText | Should -Not -BeNullOrEmpty + $result.SecureString | Should -BeOfType [securestring] + $result.ProtectedString | Should -Not -BeNullOrEmpty + $result.UsedPolicy | Should -Be 'Fallback' + + # Verify minimum length (default is 24) + $result.PlainText.Length | Should -BeGreaterOrEqual 24 + } + + It 'Generates password with custom fallback minimum length' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword -FallbackMinLength 32 + + $result.PlainText.Length | Should -BeGreaterOrEqual 32 + $result.UsedPolicy | Should -Be 'Fallback' + } + + It 'Includes uppercase characters when required' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword -FallbackRequireUpper $true + + $result.PlainText | Should -Match '[A-Z]' + } + + It 'Includes lowercase characters when required' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword -FallbackRequireLower $true + + $result.PlainText | Should -Match '[a-z]' + } + + It 'Includes digit characters when required' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword -FallbackRequireDigit $true + + $result.PlainText | Should -Match '[0-9]' + } + + It 'Includes special characters when required' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword -FallbackRequireSpecial $true -FallbackSpecialCharSet '!@#$%' + + $result.PlainText | Should -Match '[!@#$%]' + } + + It 'Uses default special character set when required but set is empty' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + # This should not throw and should use default special chars + $result = New-IdleADPassword -FallbackRequireSpecial $true -FallbackSpecialCharSet '' + + $result.PlainText | Should -Not -BeNullOrEmpty + # Should contain at least one character from the default set + $result.PlainText | Should -Match '[!@#$%&*+\-_=?]' + } + + It 'Generates password with all character classes when all requirements are true' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword ` + -FallbackRequireUpper $true ` + -FallbackRequireLower $true ` + -FallbackRequireDigit $true ` + -FallbackRequireSpecial $true + + $result.PlainText | Should -Match '[A-Z]' + $result.PlainText | Should -Match '[a-z]' + $result.PlainText | Should -Match '[0-9]' + $result.PlainText | Should -Match '[!@#$%&*+\-_=?]' + } + } + + Context 'Password generation with domain policy' { + It 'Uses domain policy when available' { + # Mock Get-ADDefaultDomainPasswordPolicy to return a policy + Mock Get-ADDefaultDomainPasswordPolicy { + return [pscustomobject]@{ + MinPasswordLength = 12 + ComplexityEnabled = $true + } + } + + $result = New-IdleADPassword + + $result.PlainText.Length | Should -BeGreaterOrEqual 12 + $result.UsedPolicy | Should -Be 'DomainPolicy' + } + + It 'Enforces fallback as minimum baseline when domain policy allows shorter passwords' { + Mock Get-ADDefaultDomainPasswordPolicy { + return [pscustomobject]@{ + MinPasswordLength = 8 + ComplexityEnabled = $false + } + } + + $result = New-IdleADPassword -FallbackMinLength 24 + + # Should use the higher of the two (24 from fallback) + $result.PlainText.Length | Should -BeGreaterOrEqual 24 + $result.UsedPolicy | Should -Be 'DomainPolicy' + } + + It 'Requires all character classes when domain complexity is enabled' { + Mock Get-ADDefaultDomainPasswordPolicy { + return [pscustomobject]@{ + MinPasswordLength = 10 + ComplexityEnabled = $true + } + } + + $result = New-IdleADPassword + + $result.PlainText | Should -Match '[A-Z]' + $result.PlainText | Should -Match '[a-z]' + $result.PlainText | Should -Match '[0-9]' + $result.PlainText | Should -Match '[!@#$%&*+\-_=?]' + } + + It 'Respects domain policy minimum length when higher than fallback' { + Mock Get-ADDefaultDomainPasswordPolicy { + return [pscustomobject]@{ + MinPasswordLength = 30 + ComplexityEnabled = $false + } + } + + $result = New-IdleADPassword -FallbackMinLength 24 + + $result.PlainText.Length | Should -BeGreaterOrEqual 30 + } + } + + Context 'Password output formats' { + It 'Returns PlainText as string' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword + + $result.PlainText | Should -BeOfType [string] + $result.PlainText | Should -Not -BeNullOrEmpty + } + + It 'Returns SecureString that can be converted back' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword + + $result.SecureString | Should -BeOfType [securestring] + + # Verify it can be converted back to plaintext + $plainFromSecure = [pscredential]::new('x', $result.SecureString).GetNetworkCredential().Password + $plainFromSecure | Should -Be $result.PlainText + } + + It 'Returns ProtectedString that can be converted to SecureString' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword + + $result.ProtectedString | Should -Not -BeNullOrEmpty + + # Verify it can be converted to SecureString + { ConvertTo-SecureString -String $result.ProtectedString } | Should -Not -Throw + } + + It 'ProtectedString round-trips correctly' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result = New-IdleADPassword + + # Convert ProtectedString -> SecureString -> PlainText + $secure = ConvertTo-SecureString -String $result.ProtectedString + $plain = [pscredential]::new('x', $secure).GetNetworkCredential().Password + + $plain | Should -Be $result.PlainText + } + } + + Context 'Edge cases and validation' { + It 'Enforces minimum AD password length of 8' { + Mock Get-ADDefaultDomainPasswordPolicy { + return [pscustomobject]@{ + MinPasswordLength = 4 + ComplexityEnabled = $false + } + } + + $result = New-IdleADPassword -FallbackMinLength 4 + + # Should be at least 8 (AD minimum) + $result.PlainText.Length | Should -BeGreaterOrEqual 8 + } + + It 'Generates different passwords on each call' { + Mock Get-ADDefaultDomainPasswordPolicy { + throw "Domain policy not available" + } + + $result1 = New-IdleADPassword + $result2 = New-IdleADPassword + + $result1.PlainText | Should -Not -Be $result2.PlainText + } + + It 'Accepts credential parameter without error' { + Mock Get-ADDefaultDomainPasswordPolicy { + param($Credential) + return [pscustomobject]@{ + MinPasswordLength = 12 + ComplexityEnabled = $true + } + } + + $fakeCred = [pscredential]::new('user', (ConvertTo-SecureString -String 'pass' -AsPlainText -Force)) + + { New-IdleADPassword -Credential $fakeCred } | Should -Not -Throw + } + } + } +}