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>
121 lines
6 KiB
C#
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.");
|
|
}
|
|
}
|