#Requires -Version 5.1 <# .SYNOPSIS Creates or repairs a SQL Server login and ensures it is a member of the sysadmin fixed server role -- the thing usually meant by "the SA group". .DESCRIPTION A thin, secure runner for Ensure-SysadminLogin.sql. It reads that file, discards everything above the --#PARAMS-END sentinel, and executes the remainder through sp_executesql with real SqlParameters. That indirection is the point: the password is bound as a parameter, so it never appears in the command line (visible to any user via the process list), in shell history, in a temp file, or in the SQL text captured by Profiler / Extended Events / the query store. Everything the script actually does -- and why -- is documented in the .sql file. This wrapper adds no logic of its own. .PARAMETER ServerInstance Target instance, e.g. "wonder-sql-vd03" or "HOST\INSTANCE,1433". .PARAMETER LoginName SQL Authentication login to create or repair. .PARAMETER Password Password as a SecureString. Prompted for (never echoed) when omitted. .PARAMETER AdminCredential Credential to CONNECT with, if not using Windows Integrated auth. Must already be a sysadmin -- granting sysadmin requires holding it. .PARAMETER NoSysadmin Create/repair the login and set its password, but do NOT grant sysadmin. .PARAMETER DisablePasswordPolicy Sets CHECK_POLICY = OFF. Only for a service account that a domain password policy would otherwise reject. Note this also forces CHECK_EXPIRATION off, and toggling the flag back on later clears the password history. .PARAMETER TrustServerCertificate Skip TLS validation of the SQL Server certificate. Required against an instance using a self-signed cert, which is the norm on the internal boxes here. It is NOT the default: this connection carries a sysadmin password, so accepting any certificate makes it interceptable. Prefer installing the issuing CA into the client trust store. .EXAMPLE ./Ensure-SysadminLogin.ps1 -ServerInstance wonder-sql-vd03 -LoginName svc_example -TrustServerCertificate .EXAMPLE $c = Get-Credential sa ./Ensure-SysadminLogin.ps1 -ServerInstance localhost,1433 -LoginName appuser -AdminCredential $c #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter(Mandatory = $true)][string] $ServerInstance, [Parameter(Mandatory = $true)][string] $LoginName, [Parameter()][securestring] $Password, [Parameter()][pscredential] $AdminCredential, [Parameter()][string] $DefaultDatabase = 'master', [Parameter()][switch] $NoSysadmin, [Parameter()][switch] $DisablePasswordPolicy, [Parameter()][switch] $TrustServerCertificate, [Parameter()][int] $ConnectTimeoutSeconds = 15, [Parameter()][string] $SqlFile ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- # Locate the .sql and take only the part below the sentinel. The parameter # DECLAREs above it exist for standalone SSMS use; keeping them here would # collide with the sp_executesql parameter list. # --------------------------------------------------------------------------- if (-not $SqlFile) { $SqlFile = Join-Path -Path $PSScriptRoot -ChildPath 'Ensure-SysadminLogin.sql' } if (-not (Test-Path -LiteralPath $SqlFile)) { throw "Cannot find the SQL script at '$SqlFile'. Pass -SqlFile to point at it." } # Matched as a whole line, not as a substring: the sentinel is also named in the # .sql header prose, and a substring search finds that mention first -- which # leaves the parameter DECLAREs in the body and collides with the sp_executesql # parameter list. Requiring exactly one whole-line match makes that failure # impossible rather than merely unlikely. $sentinel = '--#PARAMS-END' $lines = @(Get-Content -LiteralPath $SqlFile) $hits = @(0..($lines.Count - 1) | Where-Object { $lines[$_].Trim() -ceq $sentinel }) if ($hits.Count -eq 0) { throw "'$SqlFile' has no line consisting solely of $sentinel. It has been edited in a way this wrapper cannot parse; run the .sql directly in SSMS instead." } if ($hits.Count -gt 1) { throw "'$SqlFile' has $($hits.Count) $sentinel lines (at $($hits -join ', ')). Exactly one is required, or the wrapper cannot tell where the parameters end." } $body = ($lines[($hits[0] + 1)..($lines.Count - 1)]) -join [Environment]::NewLine # --------------------------------------------------------------------------- # Resolve an ADO.NET client. Microsoft.Data.SqlClient arrives with the SqlServer # module; System.Data.SqlClient is in the box on Windows PowerShell 5.1. .NET # Core ships neither, hence the explicit failure rather than a confusing # "type not found" further down. # --------------------------------------------------------------------------- function Resolve-SqlClientNamespace { foreach ($ns in 'Microsoft.Data.SqlClient', 'System.Data.SqlClient') { if ([Type]::GetType("$ns.SqlConnection, $ns", $false)) { return $ns } if ("$ns.SqlConnection" -as [Type]) { return $ns } } try { Import-Module SqlServer -ErrorAction Stop | Out-Null if ('Microsoft.Data.SqlClient.SqlConnection' -as [Type]) { return 'Microsoft.Data.SqlClient' } } catch { } try { Add-Type -AssemblyName System.Data -ErrorAction Stop if ('System.Data.SqlClient.SqlConnection' -as [Type]) { return 'System.Data.SqlClient' } } catch { } throw 'No SQL Server ADO.NET client is available. Install the SqlServer PowerShell module (Install-Module SqlServer), or run the .sql file directly in SSMS / sqlcmd.' } $ns = Resolve-SqlClientNamespace Write-Verbose "Using $ns" # --------------------------------------------------------------------------- # Prompt for the new password if it was not supplied, and confirm it. A typo in # a password that is simultaneously being set and never displayed is otherwise # undiscoverable until the first failed login. # --------------------------------------------------------------------------- if (-not $Password) { $Password = Read-Host -AsSecureString -Prompt "New password for SQL login '$LoginName'" $confirm = Read-Host -AsSecureString -Prompt 'Confirm password' $a = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password) $b = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($confirm) try { if ([Runtime.InteropServices.Marshal]::PtrToStringBSTR($a) -cne [Runtime.InteropServices.Marshal]::PtrToStringBSTR($b)) { throw 'Passwords do not match.' } } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($a) [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b) } } # --------------------------------------------------------------------------- # Connection string. # --------------------------------------------------------------------------- $csb = New-Object "$ns.SqlConnectionStringBuilder" $csb['Data Source'] = $ServerInstance $csb['Initial Catalog'] = 'master' $csb['Connect Timeout'] = $ConnectTimeoutSeconds $csb['Application Name'] = 'Ensure-SysadminLogin' if ($AdminCredential) { $csb['User ID'] = $AdminCredential.UserName $csb['Password'] = $AdminCredential.GetNetworkCredential().Password } else { $csb['Integrated Security'] = $true } if ($TrustServerCertificate) { $csb['TrustServerCertificate'] = $true Write-Warning 'TLS certificate validation is DISABLED for this connection (-TrustServerCertificate). A sysadmin password is being sent over it. Use this only on a trusted network segment.' } $target = "login '$LoginName' on '$ServerInstance'" $action = if ($NoSysadmin) { 'create/repair (no sysadmin grant)' } else { 'create/repair and grant sysadmin' } if (-not $PSCmdlet.ShouldProcess($target, $action)) { return } # --------------------------------------------------------------------------- # Execute. The body runs inside sp_executesql with the parameters it expects. # --------------------------------------------------------------------------- $outer = @' EXEC sys.sp_executesql @stmt = @Body, @params = N'@LoginName sysname, @Password nvarchar(128), @CheckPolicy bit, @GrantSysadmin bit, @DefaultDatabase sysname', @LoginName = @P_LoginName, @Password = @P_Password, @CheckPolicy = @P_CheckPolicy, @GrantSysadmin = @P_GrantSysadmin, @DefaultDatabase = @P_DefaultDatabase; '@ $conn = New-Object "$ns.SqlConnection" $csb.ConnectionString $plainPtr = [IntPtr]::Zero try { # RAISERROR ... WITH NOWAIT from the script arrives as an info message, not # as output. Without this handler the run looks silent. $conn.add_InfoMessage({ param($s, $e) foreach ($err in $e.Errors) { Write-Host " $($err.Message)" } }) $conn.Open() $cmd = $conn.CreateCommand() $cmd.CommandText = $outer $cmd.CommandTimeout = 60 $plainPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password) $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($plainPtr) [void]$cmd.Parameters.AddWithValue('@Body', $body) [void]$cmd.Parameters.AddWithValue('@P_LoginName', $LoginName) [void]$cmd.Parameters.AddWithValue('@P_Password', $plain) [void]$cmd.Parameters.AddWithValue('@P_CheckPolicy', -not $DisablePasswordPolicy) [void]$cmd.Parameters.AddWithValue('@P_GrantSysadmin', -not $NoSysadmin) [void]$cmd.Parameters.AddWithValue('@P_DefaultDatabase', $DefaultDatabase) $reader = $cmd.ExecuteReader() try { $rows = @() while ($reader.Read()) { $o = [ordered]@{} for ($i = 0; $i -lt $reader.FieldCount; $i++) { $o[$reader.GetName($i)] = $reader.GetValue($i) } $rows += [pscustomobject]$o } # Emitted as objects, so the caller can assert on them rather than # scraping formatted text. $rows } finally { $reader.Dispose() } } finally { if ($plainPtr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($plainPtr) } $conn.Dispose() }