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

251 lines
12 KiB
Transact-SQL

/*==============================================================================
Ensure-SysadminLogin.sql
----------------------------------------------------------------------------
Idempotently ensures a SQL Server *login* exists, has a known password, is
enabled and unlocked, and is a member of the sysadmin fixed server role.
Target: SQL Server 2022 (16.x). Also correct on 2012+; the only 2022-specific
content is the granular-role note at the end of the file.
ON THE NAME "SA GROUP"
----------------------
SQL Server has no group called "SA". `sa` is a single built-in *login*. The
privilege people mean by "sa rights" comes from the **sysadmin fixed server
role**, of which `sa` is merely one member. This script therefore adds the
login to `sysadmin`.
* sysadmin bypasses ALL permission checks. Members can do anything on the
instance, including dropping databases and reading every credential the
service account can decrypt. Grant it deliberately.
* ALTER SERVER ROLE is the supported syntax. sp_addsrvrolemember is
deprecated and is not used here.
* SQL Server 2022 adds granular fixed server roles that cover most reasons
people reach for sysadmin. See the note at the end of this file.
USAGE
-----
SSMS / Azure Data Studio / sqlcmd -i : edit the PARAMETERS block below and run.
Interactively with no password on disk or in shell history:
pwsh ./Ensure-SysadminLogin.ps1 -ServerInstance wonder-sql-vd03 -LoginName svc_example
The .ps1 wrapper discards everything above the --#PARAMS-END sentinel and runs
the remainder through sp_executesql with real SqlParameters, so the password
never appears in the command line, the SQL text, a temp file, or a trace.
Do not remove that sentinel, and do not declare the parameter variables below
it -- either change silently breaks the wrapper.
REQUIRES
--------
A caller holding ALTER ANY LOGIN and CONTROL SERVER (in practice, an existing
sysadmin). Anything less cannot grant sysadmin membership.
SAFETY
------
* Wrapped in a transaction with XACT_ABORT; rolls back on any error.
* Refuses to touch a principal that is not a SQL login. A Windows login or
group of the same name has no password to set, and silently skipping that
would be worse than failing.
* The password is escaped via QUOTENAME rather than concatenated raw, and is
never printed by this script.
==============================================================================*/
/*------------------------------------------------------------------------------
PARAMETERS -- edit these two (three if the password policy is in the way).
A single quote in the password must be DOUBLED here, because you are editing a
T-SQL string literal: pa'ss -> N'pa''ss'
Get that wrong and the literal ends early: the password silently becomes the
truncated prefix, and the remainder of your text is parsed as SQL and executed.
The .ps1 wrapper binds the password as a parameter and so has no such rule --
another reason to prefer it for anything but a quick manual run.
------------------------------------------------------------------------------*/
DECLARE @LoginName sysname = N'wwadmin';
DECLARE @Password nvarchar(128) = N'SomeStr0ng!Passw0rd';
/* CHECK_POLICY = 1 enforces the Windows password policy (complexity, history,
lockout) against this login. Leave it on unless a policy you do not control
is blocking a service account. Turning it OFF also forces CHECK_EXPIRATION
off, and toggling it OFF then ON later clears the password history. */
DECLARE @CheckPolicy bit = 1;
/* Set to 0 to create/fix the login but NOT grant sysadmin -- useful when only
the password-reset half is wanted. */
DECLARE @GrantSysadmin bit = 1;
DECLARE @DefaultDatabase sysname = N'master';
--#PARAMS-END
/*==============================================================================
No editing required below this line.
==============================================================================*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @sql nvarchar(max);
DECLARE @msg nvarchar(400);
DECLARE @type char(1);
DECLARE @isDisabled bit;
DECLARE @created bit = 0;
DECLARE @roleAdded bit = 0;
------------------------------------------------------------------------------
-- 0. Validate inputs before touching anything.
------------------------------------------------------------------------------
IF @LoginName IS NULL OR LTRIM(RTRIM(@LoginName)) = N'' OR @LoginName = N'CHANGE_ME'
THROW 50001, 'LoginName was not supplied. Edit the PARAMETERS block, or use the .ps1 wrapper.', 1;
IF @Password IS NULL OR @Password = N'' OR @Password = N'CHANGE_ME'
THROW 50002, 'Password was not supplied. Edit the PARAMETERS block, or use the .ps1 wrapper.', 1;
-- A backslash means a Windows principal (DOMAIN\user). Those have no SQL
-- password, so the caller has almost certainly confused the two login types.
IF CHARINDEX(N'\', @LoginName) > 0
THROW 50003, 'LoginName looks like a Windows principal (contains a backslash). This script manages SQL Authentication logins only.', 1;
------------------------------------------------------------------------------
-- 1. Can the instance even serve a SQL login?
-- IsIntegratedSecurityOnly = 1 means Windows Authentication mode. The login
-- can still be created, but nobody will ever connect with it -- so say so
-- rather than reporting a success the caller cannot use.
------------------------------------------------------------------------------
IF CONVERT(int, SERVERPROPERTY('IsIntegratedSecurityOnly')) = 1
RAISERROR('WARNING: instance is in Windows Authentication mode. The SQL login will be created but CANNOT be used to connect until Mixed Mode is enabled and the instance is restarted.', 10, 1) WITH NOWAIT;
------------------------------------------------------------------------------
-- 2. Inspect any existing principal of this name.
------------------------------------------------------------------------------
SELECT @type = sp.type,
@isDisabled = sp.is_disabled
FROM sys.server_principals AS sp
WHERE sp.name = @LoginName;
IF @type IS NOT NULL AND @type <> 'S'
BEGIN
SET @msg = FORMATMESSAGE(
'A server principal named "%s" already exists but is of type "%s" (S=SQL login, U=Windows login, G=Windows group, R=server role, C=certificate-mapped, K=asymmetric-key-mapped). Refusing to proceed: only a SQL login has a password this script can set.',
@LoginName, @type);
THROW 50004, @msg, 1;
END
BEGIN TRANSACTION;
------------------------------------------------------------------------------
-- 3. Create, or reset the password.
-- QUOTENAME with a doubled-quote delimiter is what makes the password safe
-- to embed: a password containing a single quote would otherwise terminate
-- the literal and inject SQL.
------------------------------------------------------------------------------
IF @type IS NULL
BEGIN
SET @sql = N'CREATE LOGIN ' + QUOTENAME(@LoginName)
+ N' WITH PASSWORD = ' + QUOTENAME(@Password, '''')
+ N', DEFAULT_DATABASE = ' + QUOTENAME(@DefaultDatabase)
+ N', CHECK_POLICY = ' + CASE WHEN @CheckPolicy = 1 THEN N'ON' ELSE N'OFF' END
+ CASE WHEN @CheckPolicy = 1 THEN N', CHECK_EXPIRATION = OFF' ELSE N'' END
+ N';';
EXEC sys.sp_executesql @sql;
SET @created = 1;
RAISERROR('Created SQL login.', 10, 1) WITH NOWAIT;
END
ELSE
BEGIN
-- Policy first: a password that satisfies the new policy may violate the
-- old one, so the flag has to move before the password does.
SET @sql = N'ALTER LOGIN ' + QUOTENAME(@LoginName)
+ N' WITH CHECK_POLICY = ' + CASE WHEN @CheckPolicy = 1 THEN N'ON' ELSE N'OFF' END + N';';
EXEC sys.sp_executesql @sql;
-- UNLOCK clears an account locked out by failed logins. It is only legal
-- alongside a password change, which is exactly what is happening here,
-- and only meaningful while CHECK_POLICY is ON.
SET @sql = N'ALTER LOGIN ' + QUOTENAME(@LoginName)
+ N' WITH PASSWORD = ' + QUOTENAME(@Password, '''')
+ CASE WHEN @CheckPolicy = 1 THEN N' UNLOCK' ELSE N'' END
+ N';';
EXEC sys.sp_executesql @sql;
SET @sql = N'ALTER LOGIN ' + QUOTENAME(@LoginName)
+ N' WITH DEFAULT_DATABASE = ' + QUOTENAME(@DefaultDatabase) + N';';
EXEC sys.sp_executesql @sql;
RAISERROR('Login already existed; password reset and unlocked.', 10, 1) WITH NOWAIT;
END
------------------------------------------------------------------------------
-- 4. Enable it. A disabled login will not connect no matter how good its
-- password or how high its role membership.
------------------------------------------------------------------------------
IF @created = 0 AND @isDisabled = 1
BEGIN
SET @sql = N'ALTER LOGIN ' + QUOTENAME(@LoginName) + N' ENABLE;';
EXEC sys.sp_executesql @sql;
RAISERROR('Login was disabled; re-enabled.', 10, 1) WITH NOWAIT;
END
------------------------------------------------------------------------------
-- 5. sysadmin membership -- the thing "SA group" actually means.
-- ADD MEMBER is already idempotent; the explicit check exists so the summary
-- below can distinguish "granted now" from "already had it".
------------------------------------------------------------------------------
IF @GrantSysadmin = 1
BEGIN
IF NOT EXISTS (
SELECT 1
FROM sys.server_role_members AS srm
JOIN sys.server_principals AS r ON r.principal_id = srm.role_principal_id
JOIN sys.server_principals AS m ON m.principal_id = srm.member_principal_id
WHERE r.name = N'sysadmin'
AND m.name = @LoginName)
BEGIN
SET @sql = N'ALTER SERVER ROLE [sysadmin] ADD MEMBER ' + QUOTENAME(@LoginName) + N';';
EXEC sys.sp_executesql @sql;
SET @roleAdded = 1;
RAISERROR('Added to the sysadmin fixed server role.', 10, 1) WITH NOWAIT;
END
ELSE
RAISERROR('Already a member of sysadmin; no change.', 10, 1) WITH NOWAIT;
END
COMMIT TRANSACTION;
------------------------------------------------------------------------------
-- 6. Verify from the catalog, not from what the script believes it just did.
------------------------------------------------------------------------------
SELECT LoginName = sp.name,
LoginType = sp.type_desc,
Enabled = CASE WHEN sp.is_disabled = 1 THEN 'NO' ELSE 'YES' END,
IsSysadmin = CASE WHEN IS_SRVROLEMEMBER(N'sysadmin', sp.name) = 1 THEN 'YES' ELSE 'NO' END,
PolicyChecked = CASE WHEN sl.is_policy_checked = 1 THEN 'YES' ELSE 'NO' END,
ExpirationChecked = CASE WHEN sl.is_expiration_checked = 1 THEN 'YES' ELSE 'NO' END,
LockedOut = CASE WHEN LOGINPROPERTY(sp.name, 'IsLocked') = 1 THEN 'YES' ELSE 'NO' END,
MustChange = CASE WHEN LOGINPROPERTY(sp.name, 'IsMustChange') = 1 THEN 'YES' ELSE 'NO' END,
DefaultDatabase = sp.default_database_name,
PasswordLastSet = LOGINPROPERTY(sp.name, 'PasswordLastSetTime'),
Action = CASE WHEN @created = 1 THEN 'CREATED' ELSE 'UPDATED' END
+ CASE WHEN @roleAdded = 1 THEN ' +SYSADMIN' ELSE '' END
FROM sys.server_principals AS sp
LEFT JOIN sys.sql_logins AS sl ON sl.principal_id = sp.principal_id
WHERE sp.name = @LoginName;
/*==============================================================================
IS SYSADMIN ACTUALLY WHAT YOU WANT?
On SQL Server 2022 these fixed server roles cover most reasons people reach
for sysadmin, without granting the ability to destroy the instance:
##MS_ServerStateReader## read DMVs / performance state
##MS_ServerPerformanceStateReader## the performance subset of the above
##MS_DatabaseConnector## CONNECT to every database
##MS_DefinitionReader## read metadata / object definitions
##MS_DatabaseManager## create and drop databases
##MS_LoginManager## create and drop logins
##MS_SecurityDefinitionReader## read security metadata
Same syntax: ALTER SERVER ROLE [##MS_DatabaseConnector##] ADD MEMBER [x];
A monitoring, reporting, or read-only application account almost never needs
sysadmin. An application that owns exactly one database wants db_owner in that
database instead -- a server-level role is the wrong tool for it.
==============================================================================*/