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:
Joseph Doherty
2026-09-02 23:55:53 -04:00
parent 6acc5c1908
commit 2e545b50da
3 changed files with 766 additions and 0 deletions
+286
View File
@@ -0,0 +1,286 @@
# `UG_WW_*` → app role mapping (proposal)
Maps the Zimmer NAM `UG_WW_*` Active Directory groups onto the roles each ZB.MOM.WW app
actually implements. Inputs: the 39-group inventory in [`ldap_setup.md`](ldap_setup.md)
(enumerated live 2026-08-12) and the standardized
[`CANONICAL-ROLES.md`](components/auth/spec/CANONICAL-ROLES.md).
> **Status: PROPOSAL.** The mechanical parts (`_RO_`, `_OPERATOR`, `APP_IDE`, `APP_ADMIN`)
> are safe to adopt. Four items marked **⚠ DECIDE** need a human answer before anyone
> writes them into a production config — they are listed in
> [§6](#6-open-questions--decide-before-adopting) and none of them can be resolved from the
> directory alone. Ignition's role set is deliberately out of scope.
>
> ⚠️ **ignitionoee (router): ON HOLD** (decision 2026-08-13) — do **not** set up its LDAP.
> Its role vocabulary is outside the canonical set (admin role is literally `"Admin"`),
> so no existing `UG_WW_*` group maps to it; wiring it to NAM AD requires minting **new**
> AD groups first. Revisit only once those groups exist and their mapping is decided.
## 1. The app role vocabularies (verified in source 2026-08-12)
Each app implements only a **subset** of the canonical six. This is the constraint that
shapes everything below — there is no point mapping a group to a role an app does not have.
| App | Roles it actually implements | Where the mapping lives |
|---|---|---|
| **OtOpcUa** (AdminUI) | `Viewer`, `Designer`, `Administrator` | `Security:Ldap:GroupToRole` (appsettings, merged with a DB overlay) |
| **ScadaBridge** | `Viewer`, `Operator`, `Verifier`, `Designer`, `Deployer`, `Administrator` | `LdapGroupMapping` table (database, not config) |
| **MxAccessGateway** | `Viewer`, `Administrator` | `MxGateway:Dashboard:GroupToRole` |
| **HistorianGateway** | `Viewer`, `Administrator` | `Dashboard:GroupToRole` |
Three things follow that are easy to get wrong:
- **OtOpcUa's LDAP mapping governs the AdminUI only.** Since Gitea #520 (2026-08-01) the
OPC UA data plane does not use LDAP at all — a client presents an `otua_` API key as an
OPC UA UserName token. So a plant operator who only reads tags needs **no AD group here**;
granting one gives them the configuration UI, which is not what anyone means by
"operator access". The `CANONICAL-ROLES.md` row showing OtOpcUa data-plane bundles
predates that change and no longer applies to LDAP.
- **`Administrator` is the literal string in all four apps.** MxGateway's constant is named
`DashboardRoles.Admin` but its value is `"Administrator"`, and its validator rejects
anything that is not `Administrator` or `Viewer`.
- **Every app fails closed on zero matched groups.** A user whose groups match no entry is
denied. So an unmapped group grants nothing — the mapping does not have to be exhaustive,
and leaving a group out is a safe default rather than an omission.
## 2. Group family → canonical role
| `UG_WW_*` family | Canonical | Confidence |
|---|---|---|
| `APP_IDE_RO_<env>`, `BTMGR_RO_<env>` | **Viewer** | firm — `RO` is unambiguous |
| `<SITE>_<AREA>_OPERATOR` | **Operator** | firm |
| `BTMGR_<PLANT>_<env>` | **Operator** | firm — batch execution is operate-level |
| `<SITE>_<AREA>_ENGINEER` | **Engineer** | firm canonically, **but no app implements Engineer** — see §6.1 |
| `APP_IDE_<env>` | **Designer + Deployer** | **DECIDED** — see §2.1 |
| `APP_ADMIN_<env>` | **Administrator** | firm |
| `HIST_ADMIN_<env>` | **Administrator**, historian-scoped | firm for HistorianGateway; see §4 for the others |
| `SITE_ADMIN_<SITE>` | **Administrator** (scoped) — *not* Deployer | ⚠ DECIDE — §6.2 |
| `<SITE>_<AREA>_SUPERVISOR` | **Verifier**? | ⚠ DECIDE — §6.3 |
| `<SITE>_SMS_TESTER` | — | ⚠ DECIDE — §6.4 |
| `CITRIX_<env>` | **none — deliberately unmapped** | firm — see below |
**`CITRIX_*` is not a role and must not be mapped.** It grants desktop/session access to a
Citrix farm, not authority within an application. Mapping it would hand app privileges to
everyone who merely has a published desktop — 12 people in QA alone — which is a
privilege-escalation path disguised as a convenience.
### 2.1 `APP_IDE` carries both `Designer` and `Deployer` — DECIDED
The ArchestrA IDE is both the authoring surface *and* the deployment surface: templates are
created there and pushed to runtime from the same tool, by the same person, in the same
session. So `APP_IDE_<env>` maps to **both** canonical roles, and `APP_IDE_RO_<env>` maps to
**Viewer** — read the Galaxy, change nothing, deploy nothing.
This closes §6.5: the `AUTHOR`/`DEPLOY` separation the canonical set describes **does not
exist in this estate**, and rather than inventing one, the mapping reflects how the tooling
actually works. It is a deliberate collapse, not an oversight.
**Only ScadaBridge can express it.** Its `LdapGroupMapping` is a row per *(group, role)*
pair and its `RoleMapper` accumulates matches into a `HashSet<string>`, so two rows against
the same group grant both roles. The other three apps bind `GroupToRole` as a
`Dictionary<string, string>` — **one role per group, structurally; a duplicate key is a
config error, not a second grant.** That is not a limitation in practice: none of those
three implements `Deployer` at all, so there is nothing to lose.
**Consequence for `SITE_ADMIN_*`:** §6.2 previously recommended mapping it to a site-scoped
`Deployer`. With `Deployer` now sourced from `APP_IDE`, that would mean two unrelated groups
granting the same authority by different routes, and would leave "site admin" meaning
nothing beyond what an IDE user already has. `Administrator` is the remaining reading — see
the revised §6.2, which raises rather than settles the blast-radius question.
## 3. The environment axis is per-deployment, not per-role
`DEV`/`QA`/`PROD` is **orthogonal to role** and has no representation in `CanonicalRole`.
Do not encode it into role names; that triples the role set and buys nothing.
Instead, **each deployment maps only its own environment's groups.** The mapping already
lives in per-instance config, so this is free:
- the PROD instance's config lists `UG_WW_APP_ADMIN_PROD` and never mentions `_DEV`
- the DEV instance's config lists `UG_WW_APP_ADMIN_DEV`
This is load-bearing, not cosmetic. A single config listing all three environments would
make every DEV administrator a **PROD** administrator, silently, with no error anywhere.
The blocks in §4 are written for **PROD**. For DEV/QA, substitute the suffix. Note
`CITRIX` has no `PROD` group at all, which is further reason not to treat it as a role.
## 4. Per-app configuration
### OtOpcUa — `Security:Ldap:GroupToRole`
```json
"GroupToRole": {
"UG_WW_APP_ADMIN_PROD": "Administrator",
"UG_WW_SITE_ADMIN_WW19": "Administrator",
"UG_WW_APP_IDE_PROD": "Designer",
"UG_WW_APP_IDE_RO_PROD": "Viewer",
"UG_WW_HIST_ADMIN_PROD": "Viewer"
}
```
`HIST_ADMIN` gets `Viewer` because historization *targets* are authored in the OtOpcUa
AdminUI: a historian admin needs to see how tags are historized without being able to
change the address space. Plant-floor operator and engineer groups are **deliberately
absent** — the AdminUI is a configuration surface, and their runtime access is API-key
based (§1).
Replace `WW19` with the site that owns the instance; see §6.2 before listing all eight.
### ScadaBridge — `LdapGroupMapping` table
The only app with a role set rich enough to use most of the directory, and the only one
where site scoping is real.
| Group | Role | Scope |
|---|---|---|
| `UG_WW_APP_ADMIN_PROD` | `Administrator` | — |
| `UG_WW_APP_IDE_PROD` | `Designer` **and** `Deployer` — two rows (§2.1) | — |
| `UG_WW_APP_IDE_RO_PROD` | `Viewer` | — |
| `UG_WW_BTMGR_RO_PROD` | `Viewer` | — |
| `UG_WW_SITE_ADMIN_<SITE>` | `Administrator` | that site ⚠ §6.2 |
| `UG_WW_B19_SMS_OPERATOR` | `Operator` | B19 |
| `UG_WW_B19_VitE_OPERATOR` | `Operator` | B19 |
| `UG_WW_PON_PRESS_OPERATOR` | `Operator` | PON |
| `UG_WW_WN_ARCOM_OPERATOR` | `Operator` | WN |
| `UG_WW_WN_ARCOMXL_OPERATOR` | `Operator` | WN |
| `UG_WW_WN_SOLAR_OPERATOR` | `Operator` | WN |
| `UG_WW_WN_STERIS_OPERATOR` | `Operator` | WN |
| `UG_WW_BTMGR_BE_PROD` | `Operator` | BE |
| `UG_WW_BTMGR_MC_PROD` | `Operator` | MC |
| `UG_WW_WN_STERIS_SUPERVISOR` | `Verifier`? | WN ⚠ §6.3 |
| `UG_WW_B19_VitE_ENGINEER` | ⚠ §6.1 | B19 |
| `UG_WW_PON_PRESS_ENGINEER` | ⚠ §6.1 | PON |
The IDE grant is **two rows against one group** — this is the only place in the four apps
where that is possible, and it is the row pair most likely to be entered as one:
```sql
INSERT INTO LdapGroupMapping (LdapGroupName, Role) VALUES
('UG_WW_APP_IDE_PROD', 'Designer'),
('UG_WW_APP_IDE_PROD', 'Deployer');
```
`RoleMapper` unions every matched row into a `HashSet<string>`, so the user comes out
holding both. A single row grants only what it names — there is no implicit widening, and
nothing will report the missing half.
### MxAccessGateway — `MxGateway:Dashboard:GroupToRole`
```json
"GroupToRole": {
"UG_WW_APP_ADMIN_PROD": "Administrator",
"UG_WW_APP_IDE_PROD": "Viewer",
"UG_WW_APP_IDE_RO_PROD": "Viewer"
}
```
Only two roles exist, so `Designer` collapses to `Viewer`: an IDE author has a legitimate
reason to browse Galaxy through the gateway dashboard, but no reason to administer API
keys. The gRPC surface is API-key authenticated and is **not** governed by this mapping.
### HistorianGateway — `Dashboard:GroupToRole`
```json
"GroupToRole": {
"UG_WW_HIST_ADMIN_PROD": "Administrator",
"UG_WW_APP_ADMIN_PROD": "Administrator",
"UG_WW_APP_IDE_RO_PROD": "Viewer",
"UG_WW_BTMGR_RO_PROD": "Viewer"
}
```
`HIST_ADMIN` is the exact-fit group for this app — it is the one place in the directory
whose name already means what the app's `Administrator` grants.
## 5. Blast radius
Worth doing the arithmetic before adopting, because these groups are larger than they look:
| Grant | People |
|---|--:|
| `APP_ADMIN_PROD` → Administrator | 5 |
| `SITE_ADMIN_*` → Administrator, **all 8 sites** | **~48** |
| `APP_IDE_PROD` → Designer | 4 |
| all `_OPERATOR` groups → Operator | ~87 |
Mapping all eight `SITE_ADMIN_*` groups to `Administrator` on all four apps grants roughly
**48 people full administrative control of every app**, including the ones whose site has
nothing to do with the deployment. That is the single largest risk in this document, and
it is why §6.2 exists.
## 6. Open questions — DECIDE before adopting
### 6.1 `Engineer` has no home ⚠
`_ENGINEER` maps cleanly to canonical **Engineer** (`OBSERVE` + `OPERATE` + `TUNE`), and
**no app implements it.** OtOpcUa has `Viewer`/`Designer`/`Administrator`; ScadaBridge has
`Operator`/`Verifier` but no Engineer; the two gateways have `Viewer`/`Administrator`.
Options: (a) map `_ENGINEER` → `Operator` and accept that engineers lose `TUNE`;
(b) add an `Engineer` role to ScadaBridge; (c) leave unmapped, so the 18 people in the two
`_ENGINEER` groups get nothing. **Recommendation: (a) for now** — it is the honest subset,
grants no authority the group name does not imply, and can be widened later. Do not
silently map Engineer to Administrator to "cover" `TUNE`.
### 6.2 `SITE_ADMIN_*` — how much authority, and how many sites? ⚠
**Narrowed by §2.1, not settled.** `Deployer` now comes from `APP_IDE`, so site-scoped
`Deployer` is no longer available as the gentle reading of "site admin". What remains is
`Administrator` (scoped to the site), `Viewer`, or leaving it unmapped — and those are very
far apart.
This matters more than it did. There are **eight** of these groups at 6 members each, so
`Administrator` is the maximum grant in the vocabulary being handed to as many as ~48
people (§5). If a "site admin" is really a local IT contact rather than an application
owner, `Administrator` is badly wrong.
Second question, unchanged: **which sites should a given deployment list?** Every group has
exactly 6 members and they look like the same handful of people, but that is inference from
counts, not fact. **Recommendation: list only the owning site**, and confirm what the role
means before granting `Administrator` to any of them.
### 6.3 `_SUPERVISOR` → `Verifier`? ⚠
`UG_WW_WN_STERIS_SUPERVISOR` (6 members) is the only `_SUPERVISOR` group. ScadaBridge's
`Verifier` **approves** a two-person Secured Write that an `Operator` initiates, so a
supervisor approving an operator's action is a plausible reading — but "supervisor" could
equally mean a shift lead with no approval authority at all. Confirm with the group owner.
**Whatever is decided, never map one group to both `Operator` and `Verifier`.** The control
requires two *distinct* principals; a person holding both could initiate and approve their
own write, which removes the entire point of the mechanism while leaving it looking wired.
### 6.4 `_TESTER` ⚠
`UG_WW_B19_SMS_TESTER` (13 members) has no canonical equivalent and appears only in a QA
context. Leave unmapped until someone states what it is for — 13 people is too many to
guess at.
### 6.5 `Deployer` vs `AUTHOR` separation — RESOLVED
Closed by §2.1: `APP_IDE` grants both `Designer` and `Deployer`, because the ArchestrA IDE
authors and deploys from the same tool and the same session. The canonical `AUTHOR`/`DEPLOY`
split has no counterpart in this estate, and the mapping now records that as a decision
rather than leaving it to be discovered later. Same class of accepted SoD collapse already
noted in `CANONICAL-ROLES.md`.
## 7. Prerequisites
1. **Auth `0.2.1` adopted — DONE 2026-08-13.** All four apps pin `0.2.1` (OtOpcUa
`master @ 08e33fc7`, ScadaBridge `main @ d28824d9`, MxAccessGateway `main @ 1cb14d2`,
HistorianGateway `e42ff31`, merged to `main`; all pushed to origin).
The continuation-referral defect ([`ldap_setup.md`](ldap_setup.md)) is closed at the
app level, so this mapping is exercisable once configs are authored.
2. **`UG_WW_BTMGR_RO_QA` is empty.** It appears in the QA variant of the ScadaBridge and
HistorianGateway tables; mapping it grants nobody anything today. Harmless, but do not
read a working config as evidence that the grant works.
3. **Verify with a real account before rollout.** Every group in §4 was observed on one
long-tenured architect account, which holds every admin-flavoured group and **no**
operator group. The operator mappings are therefore untested against a real operator.
`ldapprobe --self-bind <operator-account>` settles it in one run — and specifically
whether such an account has any `memberOf` at all, since the library refuses a
zero-group login (`GroupLookupFailed`) regardless of a correct password.
4. **New groups are provisioned by ServiceNow ticket** (the group descriptions are
`TASK…` ids). If §6 concludes that a group is missing — a `Verifier` counterpart, or an
`Engineer` tier — that is a ticket, not a config change.
+230
View File
@@ -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()
}
+250
View File
@@ -0,0 +1,250 @@
/*==============================================================================
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.
==============================================================================*/