From f1cd5c5767a7e87d22d5a84e41abd499dc94a2ee Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 18:39:48 +0000 Subject: [PATCH 1/2] Sync-DbaLoginSid - Add command to synchronize SQL login SIDs across instances (do Sync-DbaLoginSid) Co-authored-by: Chrissy LeMaire --- dbatools.psd1 | 1 + dbatools.psm1 | 1 + public/Sync-DbaLoginSid.ps1 | 252 +++++++++++++++++++++++++++++ tests/Sync-DbaLoginSid.Tests.ps1 | 269 +++++++++++++++++++++++++++++++ 4 files changed, 523 insertions(+) create mode 100644 public/Sync-DbaLoginSid.ps1 create mode 100644 tests/Sync-DbaLoginSid.Tests.ps1 diff --git a/dbatools.psd1 b/dbatools.psd1 index 4e9a43bb0e5a..b114c9d85fea 100644 --- a/dbatools.psd1 +++ b/dbatools.psd1 @@ -661,6 +661,7 @@ 'Sync-DbaAvailabilityGroup', 'Sync-DbaLoginPassword', 'Sync-DbaLoginPermission', + 'Sync-DbaLoginSid', 'Test-DbaAgentJobOwner', 'Test-DbaAvailabilityGroup', 'Test-DbaBackupInformation', diff --git a/dbatools.psm1 b/dbatools.psm1 index 9e352ab9349d..b3b33ab2d7a7 100644 --- a/dbatools.psm1 +++ b/dbatools.psm1 @@ -548,6 +548,7 @@ if ($PSVersionTable.PSVersion.Major -lt 5) { 'Get-DbaModule', 'Sync-DbaLoginPassword', 'Sync-DbaLoginPermission', + 'Sync-DbaLoginSid', 'New-DbaCredential', 'Get-DbaFile', 'Set-DbaDbCompression', diff --git a/public/Sync-DbaLoginSid.ps1 b/public/Sync-DbaLoginSid.ps1 new file mode 100644 index 000000000000..8327ebb287ea --- /dev/null +++ b/public/Sync-DbaLoginSid.ps1 @@ -0,0 +1,252 @@ +function Sync-DbaLoginSid { + <# + .SYNOPSIS + Synchronizes SQL Server login Security Identifiers (SIDs) between instances to fix SID mismatches. + + .DESCRIPTION + Syncs SQL Server authentication login SIDs from a source to destination instance(s) to resolve SID mismatch issues that cause orphaned database users. This function is essential for fixing inherited environments where the same login exists across multiple servers but with different SIDs. + + When SQL Server login SIDs don't match across instances, database restores and Availability Group failovers result in orphaned users that require constant repair. This command proactively fixes the root cause by aligning SIDs across your environment. + + This is particularly useful for: + - Fixing inherited environments with inconsistent login SIDs across servers + - Preparing servers for Availability Group configurations where matching SIDs are required + - Eliminating the need to repeatedly run Repair-DbaDbOrphanUser after database restores + - Standardizing login SIDs across development, staging, and production environments + - Resolving authentication issues caused by SID mismatches without changing passwords + + The function only works with SQL Server authentication logins. Windows authentication logins are automatically skipped since their SIDs are managed by Active Directory. The login must already exist on both source and destination instances - this function only updates the SID property while preserving all other login properties including passwords, permissions, roles, and database mappings. + + .PARAMETER Source + Specifies the source SQL Server instance to read login SIDs from. This should be your "gold standard" instance with the correct SIDs that you want to replicate across your environment. + You must have sysadmin access and the server version must be SQL Server 2000 or higher. + + .PARAMETER SourceSqlCredential + Specifies alternative credentials to connect to the source SQL Server instance. Use this when your current Windows credentials don't have sysadmin access to the source server. + Accepts PowerShell credentials created with Get-Credential. Supports Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated. + For MFA support, please use Connect-DbaInstance. + + .PARAMETER Destination + Specifies the destination SQL Server instance(s) where login SIDs will be updated. Accepts multiple instances to sync SIDs to several servers simultaneously. + The logins must already exist on the destination - this function only syncs SIDs, not the logins themselves. You must have sysadmin access and the server must be SQL Server 2000 or higher. + + .PARAMETER DestinationSqlCredential + Specifies alternative credentials to connect to the destination SQL Server instance(s). Use this when your current Windows credentials don't have sysadmin access to the destination server(s). + Accepts PowerShell credentials created with Get-Credential. Supports Windows Authentication, SQL Server Authentication, Active Directory - Password, and Active Directory - Integrated. + For MFA support, please use Connect-DbaInstance. + + .PARAMETER InputObject + Specifies login objects from the pipeline to sync SIDs for. Accepts login objects from Get-DbaLogin. + When using InputObject, only SQL Server authentication logins will be processed - Windows authentication logins are automatically filtered out. + This parameter enables pipeline scenarios where you can filter logins first and then sync their SIDs. + + .PARAMETER Login + Specifies which specific logins to sync SIDs for. Use this when you only want to sync SIDs for certain accounts rather than all SQL logins. + Accepts multiple login names as an array. Only SQL Server authentication logins will be processed - Windows logins are automatically skipped. + + .PARAMETER ExcludeLogin + Specifies login names to exclude from the SID sync process. Use this to skip specific accounts that shouldn't have their SIDs synced. + Commonly used to exclude service accounts, application accounts, or logins with environment-specific SIDs that should remain different between servers. + + .PARAMETER WhatIf + If this switch is enabled, no actions are performed but informational messages will be displayed that explain what would happen if the command were to run. + + .PARAMETER Confirm + If this switch is enabled, you will be prompted for confirmation before executing any operations that change state. + + .PARAMETER EnableException + By default, when something goes wrong we try to catch it, interpret it and give you a friendly warning message. + This avoids overwhelming you with "sea of red" exceptions, but is inconvenient because it basically disables advanced scripting. + Using this switch turns this "nice by default" feature off and enables you to catch exceptions with your own try/catch. + + .NOTES + Tags: Migration, Login, SID + Author: the dbatools team + Claude + + Website: https://dbatools.io + Copyright: (c) 2018 by dbatools, licensed under MIT + License: MIT https://opensource.org/licenses/MIT + + Requires sysadmin access on both source and destination instances. + + .LINK + https://dbatools.io/Sync-DbaLoginSid + + .EXAMPLE + PS C:\> Sync-DbaLoginSid -Source sql2016 -Destination sql2016ag1, sql2016ag2 + + Syncs all SQL Server authentication login SIDs from sql2016 to sql2016ag1 and sql2016ag2. Windows authentication logins are automatically skipped. + This is the typical usage for preparing Availability Group replicas where login SIDs must match. + + .EXAMPLE + PS C:\> Sync-DbaLoginSid -Source sql2016 -Destination sql2017 -Login app_user, reports_user + + Syncs SIDs only for the app_user and reports_user logins from sql2016 to sql2017. + Use this when you only need to fix SID mismatches for specific application logins. + + .EXAMPLE + PS C:\> Sync-DbaLoginSid -Source sqlprod -Destination sqldev, sqltest -ExcludeLogin sa, admin + + Syncs all SQL Server login SIDs except for sa and admin accounts from sqlprod to sqldev and sqltest. + Useful when you want to standardize most logins but keep certain administrative accounts with unique SIDs per environment. + + .EXAMPLE + PS C:\> $splatSync = @{ + >> Source = "sqlprod" + >> Destination = "sqlag1", "sqlag2", "sqlag3" + >> SourceSqlCredential = $sourceCred + >> DestinationSqlCredential = $destCred + >> EnableException = $true + >> } + PS C:\> Sync-DbaLoginSid @splatSync + + Syncs all SQL Server login SIDs across multiple AG replicas using SQL Authentication credentials for connections. + Throws exceptions on errors for easier scripting and automation. This is ideal for automated AG setup scripts. + + .EXAMPLE + PS C:\> Get-DbaLogin -SqlInstance sqlprod -Login app* | Sync-DbaLoginSid -Destination sqlag1, sqlag2 + + Gets all logins starting with "app" from sqlprod and syncs their SIDs to sqlag1 and sqlag2 using pipeline input. + Demonstrates how to filter logins first, then sync only those that match your criteria. + + .EXAMPLE + PS C:\> Sync-DbaLoginSid -Source sqlprod -Destination sqldev -WhatIf + + Shows what SID synchronization operations would be performed without actually making any changes. + Use this to preview the impact before running the actual sync operation. + #> + [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")] + param ( + [Parameter(Mandatory)] + [DbaInstanceParameter]$Source, + [PSCredential]$SourceSqlCredential, + [Parameter(Mandatory)] + [DbaInstanceParameter[]]$Destination, + [PSCredential]$DestinationSqlCredential, + [Parameter(ValueFromPipeline)] + [object[]]$InputObject, + [string[]]$Login, + [string[]]$ExcludeLogin, + [switch]$EnableException + ) + + process { + if (Test-FunctionInterrupt) { return } + + try { + $splatSource = @{ + SqlInstance = $Source + SqlCredential = $SourceSqlCredential + MinimumVersion = 8 + } + $sourceServer = Connect-DbaInstance @splatSource + } catch { + Stop-Function -Message "Failed to connect to source instance $Source" -Category ConnectionError -ErrorRecord $_ -Target $Source + return + } + + # Determine which logins to process + if ($InputObject) { + # Use logins from pipeline, filter to SQL logins only + $sourceLogins = $InputObject | Where-Object LoginType -eq "SqlLogin" + } else { + # Get logins from source server + $splatLogin = @{ + SqlInstance = $sourceServer + Login = $Login + ExcludeLogin = $ExcludeLogin + } + $sourceLogins = Get-DbaLogin @splatLogin | Where-Object LoginType -eq "SqlLogin" + } + + if (-not $sourceLogins) { + Write-Message -Level Verbose -Message "No SQL Server authentication logins found on source instance $Source" + return + } + + foreach ($dest in $Destination) { + try { + $splatDestination = @{ + SqlInstance = $dest + SqlCredential = $DestinationSqlCredential + MinimumVersion = 8 + } + $destServer = Connect-DbaInstance @splatDestination + } catch { + Stop-Function -Message "Failed to connect to destination instance $dest" -Category ConnectionError -ErrorRecord $_ -Target $dest -Continue + } + + foreach ($sourceLogin in $sourceLogins) { + $loginName = $sourceLogin.Name + + # Check if login exists on destination + $destLogin = $destServer.Logins[$loginName] + if (-not $destLogin) { + Write-Message -Level Verbose -Message "Login '$loginName' not found on destination $dest. Skipping." + continue + } + + # Verify destination login is SQL authentication + if ($destLogin.LoginType -ne "SqlLogin") { + Write-Message -Level Verbose -Message "Login '$loginName' on destination $dest is not a SQL Server login. Skipping." + continue + } + + # Get source SID + $sourceSid = $sourceLogin.Sid + $destSid = $destLogin.Sid + + # Check if SIDs already match + if ([System.BitConverter]::ToString($sourceSid) -eq [System.BitConverter]::ToString($destSid)) { + Write-Message -Level Verbose -Message "Login '$loginName' already has matching SID on destination $dest. Skipping." + [PSCustomObject]@{ + SourceServer = $sourceServer.Name + DestinationServer = $destServer.Name + Login = $loginName + Status = "AlreadyMatched" + Notes = "SIDs already match" + } + continue + } + + if ($PSCmdlet.ShouldProcess($dest, "Syncing SID for login $loginName")) { + try { + # Convert SID to hex string for ALTER LOGIN statement + $sidHex = "0x" + [System.BitConverter]::ToString($sourceSid).Replace("-", "") + + # Build and execute ALTER LOGIN statement + $sql = "ALTER LOGIN [$loginName] WITH SID = $sidHex" + Write-Message -Level Debug -Message "Executing: $sql" + + $splatQuery = @{ + SqlInstance = $destServer + Database = "master" + Query = $sql + EnableException = $true + } + $null = Invoke-DbaQuery @splatQuery + + [PSCustomObject]@{ + SourceServer = $sourceServer.Name + DestinationServer = $destServer.Name + Login = $loginName + Status = "Success" + Notes = $null + } + } catch { + $errorMessage = $_.Exception.Message + Stop-Function -Message "Failed to sync SID for login $loginName on $dest : $errorMessage" -ErrorRecord $_ -Target $loginName -Continue + + [PSCustomObject]@{ + SourceServer = $sourceServer.Name + DestinationServer = $destServer.Name + Login = $loginName + Status = "Failed" + Notes = $errorMessage + } + } + } + } + } + } +} diff --git a/tests/Sync-DbaLoginSid.Tests.ps1 b/tests/Sync-DbaLoginSid.Tests.ps1 new file mode 100644 index 000000000000..48600abbd1df --- /dev/null +++ b/tests/Sync-DbaLoginSid.Tests.ps1 @@ -0,0 +1,269 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Sync-DbaLoginSid", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + Context "Parameter validation" { + It "Should have the expected parameters" { + $hasParameters = (Get-Command $CommandName).Parameters.Values.Name | Where-Object { $PSItem -notin ("WhatIf", "Confirm") } + $expectedParameters = $TestConfig.CommonParameters + $expectedParameters += @( + "Source", + "SourceSqlCredential", + "Destination", + "DestinationSqlCredential", + "InputObject", + "Login", + "ExcludeLogin", + "EnableException" + ) + Compare-Object -ReferenceObject $expectedParameters -DifferenceObject $hasParameters | Should -BeNullOrEmpty + } + + It "Should have Source as a mandatory parameter" { + $command = Get-Command $CommandName + $command.Parameters["Source"].Attributes.Mandatory | Should -Be $true + } + + It "Should have Destination as a mandatory parameter" { + $command = Get-Command $CommandName + $command.Parameters["Destination"].Attributes.Mandatory | Should -Be $true + } + } +} + +Describe $CommandName -Tag IntegrationTests { + BeforeAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $PSDefaultParameterValues["*-Dba*:Confirm"] = $false + $PSDefaultParameterValues["*-Dba*:WarningAction"] = "SilentlyContinue" + + $primaryInstance = $TestConfig.instance2 + $secondaryInstance = $TestConfig.instance3 + + $loginName1 = "dbatoolsci_syncsid1_$(Get-Random)" + $loginName2 = "dbatoolsci_syncsid2_$(Get-Random)" + $password = ConvertTo-SecureString -String "Th1sIsMyP@ssw0rd!" -AsPlainText -Force + + # Create test logins on primary instance + $splatLogin1Primary = @{ + SqlInstance = $primaryInstance + Login = $loginName1 + SecurePassword = $password + Force = $true + } + $primaryLogin1 = New-DbaLogin @splatLogin1Primary + + $splatLogin2Primary = @{ + SqlInstance = $primaryInstance + Login = $loginName2 + SecurePassword = $password + Force = $true + } + $primaryLogin2 = New-DbaLogin @splatLogin2Primary + + # Create same logins on secondary instance with different passwords to ensure different SIDs + $password2 = ConvertTo-SecureString -String "D1fferentP@ssw0rd!" -AsPlainText -Force + + $splatLogin1Secondary = @{ + SqlInstance = $secondaryInstance + Login = $loginName1 + SecurePassword = $password2 + Force = $true + } + $secondaryLogin1 = New-DbaLogin @splatLogin1Secondary + + $splatLogin2Secondary = @{ + SqlInstance = $secondaryInstance + Login = $loginName2 + SecurePassword = $password2 + Force = $true + } + $secondaryLogin2 = New-DbaLogin @splatLogin2Secondary + + # Verify SIDs are different before sync + $primarySid1 = (Get-DbaLogin -SqlInstance $primaryInstance -Login $loginName1).Sid + $secondarySid1 = (Get-DbaLogin -SqlInstance $secondaryInstance -Login $loginName1).Sid + $global:sidsAreDifferent = ([System.BitConverter]::ToString($primarySid1) -ne [System.BitConverter]::ToString($secondarySid1)) + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + $PSDefaultParameterValues.Remove("*-Dba*:WarningAction") + } + + AfterAll { + $PSDefaultParameterValues["*-Dba*:EnableException"] = $true + $PSDefaultParameterValues["*-Dba*:WarningAction"] = "SilentlyContinue" + + $splatRemovePrimary = @{ + SqlInstance = $primaryInstance + Login = $loginName1, $loginName2 + } + $null = Remove-DbaLogin @splatRemovePrimary -ErrorAction SilentlyContinue + + $splatRemoveSecondary = @{ + SqlInstance = $secondaryInstance + Login = $loginName1, $loginName2 + } + $null = Remove-DbaLogin @splatRemoveSecondary -ErrorAction SilentlyContinue + + $PSDefaultParameterValues.Remove("*-Dba*:EnableException") + $PSDefaultParameterValues.Remove("*-Dba*:WarningAction") + } + + Context "Sync SIDs between instances" { + It "Should sync SID for a single login" { + # Skip if SIDs somehow ended up the same during setup + if (-not $global:sidsAreDifferent) { + Set-ItResult -Skipped -Because "Test logins have matching SIDs, cannot test sync" + } + + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + Login = $loginName1 + } + $result = Sync-DbaLoginSid @splatSync + + $result | Should -Not -BeNullOrEmpty + $result.Login | Should -Be $loginName1 + $result.Status | Should -BeIn "Success", "AlreadyMatched" + + # Verify SID was actually synced + $primarySid = (Get-DbaLogin -SqlInstance $primaryInstance -Login $loginName1).Sid + $secondarySid = (Get-DbaLogin -SqlInstance $secondaryInstance -Login $loginName1).Sid + [System.BitConverter]::ToString($primarySid) | Should -Be ([System.BitConverter]::ToString($secondarySid)) + } + + It "Should sync SIDs for multiple logins" { + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + Login = $loginName1, $loginName2 + } + $results = Sync-DbaLoginSid @splatSync + + $results.Status.Count | Should -BeGreaterThan 0 + $results.Status | Should -Not -Contain "Failed" + } + + It "Should skip logins that do not exist on destination" { + $nonExistentLogin = "dbatoolsci_nonexistent_$(Get-Random)" + + # Create login on primary only + $splatLogin = @{ + SqlInstance = $primaryInstance + Login = $nonExistentLogin + SecurePassword = $password + Force = $true + } + $null = New-DbaLogin @splatLogin + + # Ensure login does NOT exist on destination + $null = Remove-DbaLogin -SqlInstance $secondaryInstance -Login $nonExistentLogin -Confirm:$false -ErrorAction SilentlyContinue + + # Try to sync - should skip because login doesn't exist on destination + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + Login = $nonExistentLogin + } + $result = Sync-DbaLoginSid @splatSync -WarningAction SilentlyContinue + + $result | Should -BeNullOrEmpty + + # Cleanup + $splatRemoveLogin = @{ + SqlInstance = $primaryInstance + Login = $nonExistentLogin + Confirm = $false + } + $null = Remove-DbaLogin @splatRemoveLogin -ErrorAction SilentlyContinue + } + + It "Should support pipeline input from Get-DbaLogin" { + $sourceLogin = Get-DbaLogin -SqlInstance $primaryInstance -Login $loginName2 + + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + } + $result = $sourceLogin | Sync-DbaLoginSid @splatSync + + $result | Should -Not -BeNullOrEmpty + $result.Status | Should -BeIn "Success", "AlreadyMatched" + } + + It "Should support pipeline input with multiple logins" { + # Recreate logins with different SIDs for this test + $password3 = ConvertTo-SecureString -String "An0th3rP@ssw0rd!" -AsPlainText -Force + + $splatRecreate = @{ + SqlInstance = $secondaryInstance + Login = $loginName1, $loginName2 + SecurePassword = $password3 + Force = $true + } + $null = New-DbaLogin @splatRecreate + + $sourceLogins = Get-DbaLogin -SqlInstance $primaryInstance -Login $loginName1, $loginName2 + + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + } + $results = $sourceLogins | Sync-DbaLoginSid @splatSync + + $results.Status.Count | Should -BeGreaterThan 0 + $results.Status | Should -Not -Contain "Failed" + $results.Login | Should -Contain $loginName1 + $results.Login | Should -Contain $loginName2 + } + + It "Should filter out Windows logins when using pipeline input" { + # Get the first Windows login that exists on the instance + $splatGetWinLogin = @{ + SqlInstance = $primaryInstance + Type = "Windows" + } + $windowsLogin = Get-DbaLogin @splatGetWinLogin | Select-Object -First 1 + + # Skip test if no Windows logins found on instance + if (-not $windowsLogin) { + Set-ItResult -Skipped -Because "No Windows logins found on test instance" + } + + # Get both SQL and Windows logins + $sqlLogin = Get-DbaLogin -SqlInstance $primaryInstance -Login $loginName1 + $mixedLogins = @($sqlLogin, $windowsLogin) + + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + } + $results = $mixedLogins | Sync-DbaLoginSid @splatSync + + # Should only process SQL logins, not Windows logins + $results.Login | Should -Not -Contain $windowsLogin.Name + $results.Login | Should -Contain $loginName1 + } + + It "Should skip logins that already have matching SIDs" { + # First sync to ensure SIDs match + $splatSync = @{ + Source = $primaryInstance + Destination = $secondaryInstance + Login = $loginName1 + } + $null = Sync-DbaLoginSid @splatSync + + # Try syncing again - should report already matched + $result = Sync-DbaLoginSid @splatSync + + $result.Status | Should -Be "AlreadyMatched" + $result.Notes | Should -Be "SIDs already match" + } + } +} From 04ea37ab402ec28b208c767150f71111a7c6700a Mon Sep 17 00:00:00 2001 From: Chrissy LeMaire Date: Sun, 9 Nov 2025 09:10:51 +0100 Subject: [PATCH 2/2] Preserve password and roles when syncing login SID Sync-DbaLoginSid now retrieves and preserves the destination login's password hash, server roles, and properties when syncing the SID. The login is dropped and recreated with the original password, SID, default database, language, policy settings, and roles, ensuring a seamless migration. (do Sync-DbaLoginSid) --- public/Sync-DbaLoginSid.ps1 | 80 +++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/public/Sync-DbaLoginSid.ps1 b/public/Sync-DbaLoginSid.ps1 index 8327ebb287ea..d65b63d41115 100644 --- a/public/Sync-DbaLoginSid.ps1 +++ b/public/Sync-DbaLoginSid.ps1 @@ -211,20 +211,86 @@ function Sync-DbaLoginSid { if ($PSCmdlet.ShouldProcess($dest, "Syncing SID for login $loginName")) { try { - # Convert SID to hex string for ALTER LOGIN statement + # Get the password hash from DESTINATION to preserve existing password + $passwordHash = Get-LoginPasswordHash -Login $destLogin + + if (-not $passwordHash) { + Stop-Function -Message "Failed to retrieve password hash for login $loginName from destination" -Target $loginName -Continue + [PSCustomObject]@{ + SourceServer = $sourceServer.Name + DestinationServer = $destServer.Name + Login = $loginName + Status = "Failed" + Notes = "Could not retrieve password hash" + } + continue + } + + # Convert SID to hex string for CREATE LOGIN statement $sidHex = "0x" + [System.BitConverter]::ToString($sourceSid).Replace("-", "") - # Build and execute ALTER LOGIN statement - $sql = "ALTER LOGIN [$loginName] WITH SID = $sidHex" - Write-Message -Level Debug -Message "Executing: $sql" + # Get login properties from destination before dropping + $defaultDb = $destLogin.DefaultDatabase + $language = $destLogin.Language + $isDisabled = $destLogin.IsDisabled + $denyLogin = $destLogin.DenyWindowsLogin + $checkPolicy = if ($destLogin.PasswordPolicyEnforced) { "ON" } else { "OFF" } + $checkExpiration = if ($destLogin.PasswordExpirationEnabled) { "ON" } else { "OFF" } + + # Save server roles before dropping + $serverRoles = New-Object System.Collections.ArrayList + foreach ($role in $destServer.Roles) { + if ($role.EnumMemberNames() -contains $loginName) { + $null = $serverRoles.Add($role.Name) + } + } + + # Build DROP and CREATE statements + $dropSql = "DROP LOGIN [$loginName]" + $createSql = "CREATE LOGIN [$loginName] WITH PASSWORD = $passwordHash HASHED, SID = $sidHex, DEFAULT_DATABASE = [$defaultDb], CHECK_POLICY = $checkPolicy, CHECK_EXPIRATION = $checkExpiration, DEFAULT_LANGUAGE = [$language]" + + Write-Message -Level Debug -Message "Executing: $dropSql" + Write-Message -Level Debug -Message "Executing: $createSql" + + $splatDrop = @{ + SqlInstance = $destServer + Database = "master" + Query = $dropSql + EnableException = $true + } + $null = Invoke-DbaQuery @splatDrop - $splatQuery = @{ + $splatCreate = @{ SqlInstance = $destServer Database = "master" - Query = $sql + Query = $createSql EnableException = $true } - $null = Invoke-DbaQuery @splatQuery + $null = Invoke-DbaQuery @splatCreate + + # Refresh the login object to get the newly created login + $destServer.Logins.Refresh() + $newLogin = $destServer.Logins[$loginName] + + # Restore server roles + foreach ($roleName in $serverRoles) { + $splatRole = @{ + SqlInstance = $destServer + Database = "master" + Query = "ALTER SERVER ROLE [$roleName] ADD MEMBER [$loginName]" + EnableException = $true + } + $null = Invoke-DbaQuery @splatRole + } + + # Restore disabled/denied state + if ($isDisabled) { + $newLogin.Disable() + } + if ($denyLogin) { + $newLogin.DenyWindowsLogin = $true + $newLogin.Alter() + } [PSCustomObject]@{ SourceServer = $sourceServer.Name