Files
scadaproj/tools/sql/Ensure-SysadminLogin.ps1
T

231 lines
10 KiB
PowerShell

#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()
}