Last active
August 7, 2026 14:46
-
-
Save tcartwright/b7290feb570e8676a84a8a8271c9a3d9 to your computer and use it in GitHub Desktop.
SQL Server: Create login with different passwords per environments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /*============================================================================== | |
| 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