sqlinsight-pro/tests/SQLInsight.Servers.Tests/GrantScriptTests.cs
Søren Eggert Lundsteen Olsen 697d243658 Review 1.5: grant-windows-server.ps1 backs up, removes exactly what it gave, and never more than read
The grant script changes SCM and WMI security on customer servers. Before, a re-run
replaced every ACE of the account on a WMI namespace (also a customer's Deny), -Remove
took the account out of groups and ACEs it had before, only the SCM SDDL was backed up
(and never used), Remote Management Users also opened PowerShell Remoting, and
-EnableFirewall narrowed Windows' built-in rules for every other tool and was never undone.

- Checks everything before changing anything: elevation, the account resolves (broad
  groups refused), Deny ACEs stop the script, -EnableFirewall needs -AgentAddress.
- Backs up the original SDDLs (SCM, WMI namespaces, WinRM), group memberships and
  firewall rules before the first change; records what it added in a state file in an
  administrators-only folder; -Remove removes only that.
- SDDL handled with RawSecurityDescriptor (GetSD/SetSD, sc sdshow/sdset): exactly one
  ACE added in canonical order, verified before writing and after; aliases (NS) work.
- No Remote Management Users: -WinRM adds GENERIC_READ (no Execute) to WinRM's RootSDDL;
  DCOM is the default.
- Firewall: its own "SQLInsight Agent: ..." rules only from the agent's address,
  removed again with -Remove; Windows' rules untouched.
- -WhatIf, groups by SID, Danish messages, PowerShell 2.0 syntax.
- ServerGrantScript.For does not fill in an account that could end the PowerShell
  string (typographic quotes, control characters); tests run the SDDL logic in
  Windows PowerShell against real default SDDLs.
- docs/windows-servere.md: rights, safety net, firewall.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:31:49 +02:00

121 lines
6 KiB
C#

using System.Diagnostics;
using System.Text;
namespace SQLInsight.Servers.Tests;
/// <summary>
/// The security descriptor logic of db/permissions/grant-windows-server.ps1, run in Windows PowerShell against real
/// default SDDLs (Service Control Manager, a WMI namespace, WinRM's RootSDDL): exactly one ACE is added in canonical
/// order, an account that already has the right or has a Deny ACE is left alone, and removing gives back the original
/// SDDL character for character. Nothing on this computer is read or changed.
/// </summary>
public sealed class GrantScriptTests
{
private const string Harness = """
param([string] $Script)
$ErrorActionPreference = 'Stop'
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Script, [ref] $null, [ref] $null)
foreach ($f in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) {
if ('Get-Aces', 'Test-Denied', 'Test-Allowed', 'Remove-Ace', 'Add-Ace' -contains $f.Name) { . ([scriptblock]::Create($f.Extent.Text)) }
}
$accessAllowed = [Security.AccessControl.AceQualifier]::AccessAllowed
$accessDenied = [Security.AccessControl.AceQualifier]::AccessDenied
$allSections = [Security.AccessControl.AccessControlSections]::All
$sidObj = New-Object Security.Principal.SecurityIdentifier('S-1-5-20')
$scm = 'D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)S:(AU;FA;KA;;;WD)(AU;OIIOFA;GA;;;WD)'
$new = Add-Ace $scm 0x20015
"scm-new=$new"
"scm-allowed=$(Test-Allowed $new 0x20015)"
"scm-back=$((Remove-Ace $new 0x20015) -eq $scm)"
$sidObj = New-Object Security.Principal.SecurityIdentifier('S-1-5-21-1-2-3-1105')
$wmi = 'O:BAG:BAD:(A;CIID;CCDCLCSWRPWPRCWD;;;BA)(A;CIID;CCDCRP;;;NS)(A;CIID;CCDCRP;;;LS)(A;CIID;CCDCRP;;;AU)'
$w = Add-Ace $wmi 0x21
"wmi-new=$w"
"wmi-back=$((Remove-Ace $w 0x21) -eq $wmi)"
$root = 'O:NSG:BAD:P(A;;GA;;;BA)(A;;GR;;;IU)S:P(AU;FA;GA;;;WD)(AU;SA;GXGW;;;WD)'
$r = Add-Ace $root ([int] 0x80000000)
"root-new=$r"
"root-back=$((Remove-Ace $r ([int] 0x80000000)) -eq $root)"
$pre = 'D:(A;;CCLCRPRC;;;S-1-5-21-1-2-3-1105)(A;;CC;;;AU)'
"pre-allowed=$(Test-Allowed $pre 0x20015)"
"pre-remove-other-mask=$((Remove-Ace $pre 0x21) -eq $pre)"
"deny=$(Test-Denied 'D:(D;;CC;;;S-1-5-21-1-2-3-1105)(A;;CC;;;AU)')"
"no-deny=$(Test-Denied $scm)"
""";
[Fact]
public async Task Exactly_one_ace_is_added_and_removing_it_restores_the_original()
{
var output = await RunAsync();
Assert.Equal("D:(A;;CC;;;AU)(A;;CCLCRPRC;;;IU)(A;;CCLCRPRC;;;SU)(A;;CCLCRPWPRC;;;SY)(A;;KA;;;BA)(A;;CC;;;AC)(A;;CCLCRPRC;;;NS)S:(AU;FA;KA;;;WD)(AU;OIIOFA;GA;;;WD)", output["scm-new"]);
Assert.Equal("True", output["scm-allowed"]);
Assert.Equal("True", output["scm-back"]);
// WMI: Enable Account (CC) + Remote Enable (WP), not inherited, before the inherited ACEs.
Assert.Equal("O:BAG:BAD:(A;;CCWP;;;S-1-5-21-1-2-3-1105)(A;CIID;CCDCLCSWRPWPRCWD;;;BA)(A;CIID;CCDCRP;;;NS)(A;CIID;CCDCRP;;;LS)(A;CIID;CCDCRP;;;AU)", output["wmi-new"]);
Assert.Equal("True", output["wmi-back"]);
// WinRM: read (GR), never execute (GX).
Assert.Equal("O:NSG:BAD:P(A;;GA;;;BA)(A;;GR;;;IU)(A;;GR;;;S-1-5-21-1-2-3-1105)S:P(AU;FA;GA;;;WD)(AU;SA;GXGW;;;WD)", output["root-new"]);
Assert.Equal("True", output["root-back"]);
Assert.Equal("True", output["pre-allowed"]); // already there: the script adds nothing (and -Remove takes nothing)
Assert.Equal("True", output["pre-remove-other-mask"]); // only the script's own ACE is ever removed
Assert.Equal("True", output["deny"]); // a Deny ACE stops the script
Assert.Equal("False", output["no-deny"]);
}
private static async Task<Dictionary<string, string>> RunAsync()
{
var script = Path.Combine(RepositoryRoot(), "db", "permissions", "grant-windows-server.ps1");
var harness = Path.Combine(Path.GetTempPath(), $"sqlinsight-grant-{Guid.NewGuid():N}.ps1");
await File.WriteAllTextAsync(harness, Harness, new UTF8Encoding(true));
try
{
var start = new ProcessStartInfo("powershell.exe")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
};
foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", harness, "-Script", script })
{
start.ArgumentList.Add(argument);
}
using var process = Process.Start(start)!;
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var stdout = process.StandardOutput.ReadToEndAsync(timeout.Token);
var stderr = process.StandardError.ReadToEndAsync(timeout.Token);
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await stderr);
return (await stdout).Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(l => l.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1], StringComparer.Ordinal);
}
finally
{
File.Delete(harness);
}
}
private static string RepositoryRoot()
{
for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "SQLInsight.slnx")))
{
return directory.FullName;
}
}
throw new DirectoryNotFoundException("SQLInsight.slnx blev ikke fundet over testens mappe.");
}
}