docs+tools: UG_WW_* AD group -> app role mapping proposal (ldap_mapping.md); tools/sql/Ensure-SysadminLogin (create/repair a SQL login with a parameter-bound password, optional sysadmin grant)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
#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()
|
||||
}
|
||||
Reference in New Issue
Block a user