2e545b50da
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
231 lines
10 KiB
PowerShell
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()
|
|
}
|