Skip to content

Instantly share code, notes, and snippets.

@tcartwright
Last active August 7, 2026 14:46
Show Gist options
  • Select an option

  • Save tcartwright/b7290feb570e8676a84a8a8271c9a3d9 to your computer and use it in GitHub Desktop.

Select an option

Save tcartwright/b7290feb570e8676a84a8a8271c9a3d9 to your computer and use it in GitHub Desktop.
SQL Server: Create login with different passwords per environments
/*==============================================================================
Provision / Rotate a Login by Environment + Grant Role in User DBs
------------------------------------------------------------------------------
Supports BOTH SQL logins and Windows logins/groups, selected via @LoginType.
1. (SQL only) Reads the 'environment' database extended property and selects
the password (shared across all pre-prod environments; a separate one for
prod). Windows logins have no password, so this step is skipped for them.
2. CREATEs the target login (if missing) or, for SQL logins, ALTERs its
password (if it already exists). Windows logins that already exist are a
no-op (nothing to rotate).
3. In every ONLINE, writable user database (excluding the databases listed in
@ExcludedDatabases, e.g. dbatools), ensures a database user mapped to the
login exists and adds it to @RoleName.
- Idempotent: safe to re-run.
- @DryRun previews all actions (SQL password masked, per-db batches printed)
with no changes applied.
- Graceful degradation: aborts cleanly if the login type is invalid, if (for
SQL) the env can't be resolved / is unrecognized / has no configured
password, or if the name is already taken by a principal of an incompatible
type. Per database, if the role doesn't exist it warns and skips that
database rather than failing the whole run.
Login type notes:
@LoginType = 'SQL' -> server principal type 'S' (SQL login).
@LoginType = 'WINDOWS' -> server principal type 'U' (Windows login) or
'G' (Windows group). Set @LoginName to the
qualified account, e.g. N'CONTOSO\svc_app' or a
group like N'CONTOSO\App_Readers'.
==============================================================================*/
SET NOCOUNT ON;
------------------------------------------------------------------------------
-- Configuration
------------------------------------------------------------------------------
DECLARE @DryRun BIT = 1; -- 1 = preview only, 0 = apply
DECLARE @LoginType VARCHAR(10) = 'SQL'; -- 'SQL' or 'WINDOWS'
DECLARE @LoginName SYSNAME = N'app_service_login'; -- for WINDOWS use e.g. N'CONTOSO\svc_app'
DECLARE @DefaultDatabase SYSNAME = N'master';
DECLARE @CheckPolicy BIT = 1; -- CHECK_POLICY on create (SQL only)
DECLARE @CheckExpiration BIT = 0; -- CHECK_EXPIRATION on create (SQL only)
DECLARE @RoleName SYSNAME = N'db_datareader'; -- role to grant in each user db
-- Passwords (SQL logins only). dev / qa / staging all share @PwdPreprod;
-- prod uses @PwdProd. Replace the placeholders before running. Prefer sourcing
-- these from an Azure DevOps variable group / secret store injected at deploy
-- time rather than committing literals to source. Ignored when @LoginType = 'WINDOWS'.
DECLARE @PwdPreprod NVARCHAR(128) = N'<<PREPROD_PASSWORD>>';
DECLARE @PwdProd NVARCHAR(128) = N'<<PROD_PASSWORD>>';
-- Databases to skip when granting the role (seeded with dbatools; add more rows
-- as needed). System databases are always skipped regardless of this list.
DECLARE @ExcludedDatabases TABLE ([name] SYSNAME PRIMARY KEY);
INSERT INTO @ExcludedDatabases ([name]) VALUES (N'dbatools');
------------------------------------------------------------------------------
-- Validate / normalize the requested login type
------------------------------------------------------------------------------
SET @LoginType = UPPER(LTRIM(RTRIM(@LoginType)));
IF @LoginType NOT IN ('SQL', 'WINDOWS')
BEGIN
RAISERROR('Invalid @LoginType ''%s''. Use ''SQL'' or ''WINDOWS''.', 16, 10, @LoginType) WITH NOWAIT;
RETURN;
END
RAISERROR('Requested login type: %s', 0, 1, @LoginType) WITH NOWAIT;
------------------------------------------------------------------------------
-- Resolve environment + select password (SQL logins only)
------------------------------------------------------------------------------
DECLARE @environment VARCHAR(50) = NULL;
DECLARE @Password NVARCHAR(128) = NULL;
IF @LoginType = 'SQL'
BEGIN
-- Resolve environment from the database extended property
SELECT @environment = CAST([ep].[value] AS VARCHAR(50))
FROM sys.[extended_properties] AS [ep]
WHERE [ep].[class_desc] = 'DATABASE'
AND [ep].[name] = 'environment';
IF @environment IS NULL
BEGIN
RAISERROR('Environment name could not be queried.', 16, 10);
RETURN;
END
-- Normalize (trim + lowercase) so trailing spaces / casing don't break matching
SET @environment = LOWER(LTRIM(RTRIM(@environment)));
RAISERROR('Resolved environment: %s', 0, 1, @environment) WITH NOWAIT;
-- Select the password for the resolved environment
IF @environment IN ('dev', 'qa', 'staging')
SET @Password = @PwdPreprod;
ELSE IF @environment = 'prod'
SET @Password = @PwdProd;
ELSE
BEGIN
RAISERROR('Unrecognized environment value: ''%s''.', 16, 10, @environment) WITH NOWAIT;
RETURN;
END
IF @Password IS NULL OR @Password = N'' OR @Password LIKE N'<<%>>'
BEGIN
RAISERROR('No password configured for environment ''%s''. Set the matching @Pwd variable.', 16, 10, @environment) WITH NOWAIT;
RETURN;
END
END
ELSE
BEGIN
RAISERROR('Windows login: environment/password resolution skipped (no password to manage).', 0, 1) WITH NOWAIT;
END
------------------------------------------------------------------------------
-- Determine current state of the target server principal
------------------------------------------------------------------------------
DECLARE @ExistingType CHAR(1);
SELECT @ExistingType = [type]
FROM sys.server_principals
WHERE [name] = @LoginName;
-- Name exists but is an incompatible principal type for the requested login -> abort
IF @LoginType = 'SQL'
BEGIN
-- Expect a SQL login ('S'). Anything else (Windows login/group, etc.) is a conflict.
IF @ExistingType IS NOT NULL AND @ExistingType <> 'S'
BEGIN
RAISERROR('A principal named [%s] already exists but is not a SQL login (type=%s). Aborting.',
16, 10, @LoginName, @ExistingType) WITH NOWAIT;
RETURN;
END
END
ELSE -- WINDOWS
BEGIN
-- Expect a Windows login ('U') or Windows group ('G'). Anything else is a conflict.
IF @ExistingType IS NOT NULL AND @ExistingType NOT IN ('U', 'G')
BEGIN
RAISERROR('A principal named [%s] already exists but is not a Windows login/group (type=%s). Aborting.',
16, 10, @LoginName, @ExistingType) WITH NOWAIT;
RETURN;
END
END
------------------------------------------------------------------------------
-- Build the CREATE / ALTER login statement
-- @sql = NULL means "nothing to do" (e.g. Windows login already present)
------------------------------------------------------------------------------
DECLARE @sql NVARCHAR(MAX) = NULL;
DECLARE @PwdLiteral NVARCHAR(260) = REPLACE(ISNULL(@Password, N''), N'''', N''''''); -- escape quotes (SQL only)
DECLARE @LoginBracketed SYSNAME = QUOTENAME(@LoginName);
DECLARE @PolicyClause NVARCHAR(200);
IF @LoginType = 'SQL'
BEGIN
SET @PolicyClause = CONCAT(
N'CHECK_POLICY = ', CASE WHEN @CheckPolicy = 1 THEN N'ON' ELSE N'OFF' END,
N', CHECK_EXPIRATION = ', CASE WHEN @CheckExpiration = 1 THEN N'ON' ELSE N'OFF' END
);
IF @ExistingType = 'S'
BEGIN
-- Login exists -> rotate password only
SET @sql = CONCAT(
N'ALTER LOGIN ', @LoginBracketed,
N' WITH PASSWORD = N''', @PwdLiteral, N''';'
);
RAISERROR('Login [%s] exists. Action: UPDATE PASSWORD.', 0, 1, @LoginName) WITH NOWAIT;
END
ELSE
BEGIN
-- Login missing -> create it
SET @sql = CONCAT(
N'CREATE LOGIN ', @LoginBracketed,
N' WITH PASSWORD = N''', @PwdLiteral, N''', ',
N'DEFAULT_DATABASE = ', QUOTENAME(@DefaultDatabase), N', ',
@PolicyClause, N';'
);
RAISERROR('Login [%s] not found. Action: CREATE LOGIN.', 0, 1, @LoginName) WITH NOWAIT;
END
END
ELSE -- WINDOWS
BEGIN
IF @ExistingType IN ('U', 'G')
BEGIN
-- Windows login/group already present -> nothing to rotate.
RAISERROR('Windows login [%s] already exists (type=%s). Action: NONE (no password to rotate).',
0, 1, @LoginName, @ExistingType) WITH NOWAIT;
-- @sql stays NULL; role grant below still runs.
END
ELSE
BEGIN
-- Windows login/group missing -> create it (FROM WINDOWS, no password, no policy).
SET @sql = CONCAT(
N'CREATE LOGIN ', @LoginBracketed,
N' FROM WINDOWS WITH DEFAULT_DATABASE = ', QUOTENAME(@DefaultDatabase), N';'
);
RAISERROR('Windows login [%s] not found. Action: CREATE LOGIN FROM WINDOWS.', 0, 1, @LoginName) WITH NOWAIT;
END
END
------------------------------------------------------------------------------
-- Apply (or preview) the login change
------------------------------------------------------------------------------
IF @sql IS NULL
BEGIN
RAISERROR('No login statement to run (login already present with nothing to change).', 0, 1) WITH NOWAIT;
END
ELSE IF @DryRun = 1
BEGIN
RAISERROR('--- DRY RUN: no changes applied. Login statement that WOULD run: ---', 0, 1) WITH NOWAIT;
-- Mask the password only for SQL logins; Windows statements carry no secret.
IF @LoginType = 'SQL'
PRINT REPLACE(@sql, @PwdLiteral, N'********');
ELSE
PRINT @sql;
END
ELSE
BEGIN
EXEC sys.sp_executesql @sql;
RAISERROR('Login [%s] provisioned/updated successfully (type=%s, environment=''%s'').',
0, 1, @LoginName, @LoginType, @environment) WITH NOWAIT;
END
------------------------------------------------------------------------------
-- Grant @RoleName in every eligible user database
-- (identical for SQL and Windows logins; the DB user maps to the login)
------------------------------------------------------------------------------
RAISERROR('--- Role grant: adding [%s] to role [%s] across user databases ---',
0, 1, @LoginName, @RoleName) WITH NOWAIT;
DECLARE @crlf NCHAR(2) = NCHAR(13) + NCHAR(10);
DECLARE @LoginLit NVARCHAR(260) = REPLACE(@LoginName, N'''', N''''''); -- for inner literals
DECLARE @RoleLit NVARCHAR(260) = REPLACE(@RoleName, N'''', N'''''');
DECLARE @RoleQ SYSNAME = QUOTENAME(@RoleName);
DECLARE @db SYSNAME;
DECLARE @dbLit NVARCHAR(260);
DECLARE @batch NVARCHAR(MAX);
DECLARE db_cursor CURSOR LOCAL FAST_FORWARD FOR
SELECT [name]
FROM sys.databases
WHERE database_id > 4 -- skip master, tempdb, model, msdb
AND state_desc = 'ONLINE'
AND is_read_only = 0
AND source_database_id IS NULL -- skip snapshots
AND [name] NOT IN (SELECT [name] FROM @ExcludedDatabases)
ORDER BY [name];
OPEN db_cursor;
FETCH NEXT FROM db_cursor INTO @db;
WHILE @@FETCH_STATUS = 0
BEGIN
SET @dbLit = REPLACE(@db, N'''', N'''''');
-- Per-database batch: create user if missing, add to role if not already a member.
-- Type IN ('S','U','G') covers SQL users, Windows users, and Windows groups.
SET @batch = CONCAT(
N'USE ', QUOTENAME(@db), N';', @crlf,
N'IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE [name] = N''', @LoginLit, N''' AND [type] IN (''S'',''U'',''G''))', @crlf,
N' CREATE USER ', @LoginBracketed, N' FOR LOGIN ', @LoginBracketed, N';', @crlf,
N'IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE [name] = N''', @RoleLit, N''' AND [type] = ''R'')', @crlf,
N' RAISERROR(''Role [', @RoleLit, N'] not found in database [', @dbLit, N']; membership skipped.'', 10, 1) WITH NOWAIT;', @crlf,
N'ELSE IF NOT EXISTS (', @crlf,
N' SELECT 1', @crlf,
N' FROM sys.database_role_members AS rm', @crlf,
N' INNER JOIN sys.database_principals AS r ON r.principal_id = rm.role_principal_id', @crlf,
N' INNER JOIN sys.database_principals AS m ON m.principal_id = rm.member_principal_id', @crlf,
N' WHERE r.[name] = N''', @RoleLit, N''' AND m.[name] = N''', @LoginLit, N''')', @crlf,
N' ALTER ROLE ', @RoleQ, N' ADD MEMBER ', @LoginBracketed, N';'
);
IF @DryRun = 1
BEGIN
RAISERROR(' [DRY RUN] Batch for [%s]:', 0, 1, @db) WITH NOWAIT;
PRINT @batch;
END
ELSE
BEGIN
RAISERROR(' Applying to [%s]...', 0, 1, @db) WITH NOWAIT;
EXEC sys.sp_executesql @batch;
END
FETCH NEXT FROM db_cursor INTO @db;
END
CLOSE db_cursor;
DEALLOCATE db_cursor;
RAISERROR('--- Role grant complete. ---', 0, 1) WITH NOWAIT;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment