sqlinsight-pro/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs
Søren Eggert Lundsteen Olsen 2adeef4597 Review 1.3: local Administrators may change the configuration until AdminGroups is set; MSI property ADMIN_GROUPS
With SQLInsight:Web:AdminGroups empty nobody could add the first server. The
installer now takes ADMIN_GROUPS (';'-separated) and writes it to the web's
sqlinsight.ini; while the setting is empty, members of the web server's
BUILTIN\Administrators (S-1-5-32-544 among the user's group SIDs; a remote
Negotiate login has the full token, a UAC-filtered deny-only SID does not count)
may change it. The page says who may.

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

299 lines
18 KiB
C#

using System.Net;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using SQLInsight.Core.Configuration;
using SQLInsight.Repository;
using SQLInsight.Web;
using SQLInsight.Web.Api;
namespace SQLInsight.IntegrationTests;
/// <summary>Servere og databaser in SQLInsight Web: adding, testing, choosing databases, the GRANT script and passwords.</summary>
[Collection(SqlServerTestGroup.Name)]
[Trait("Category", "Integration")]
public sealed partial class InstanceSetupWebTests(SqlServerFixture sql) : IAsyncLifetime
{
private const string Password = "Hemmelig-Kode-1!";
private readonly string _database = "SQLInsightSetup_" + Guid.NewGuid().ToString("N")[..8];
private readonly RSA _agentKey = RSA.Create(3072);
private WebApplicationFactory<Program> _factory = null!;
private InsightRepository _repository = null!;
[GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")]
private static partial Regex AntiforgeryField();
public async Task InitializeAsync()
{
await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
_repository = new InsightRepository(sql.DatabaseConnectionString(_database));
await _repository.MigrateAsync();
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(b =>
{
b.UseEnvironment("Development");
b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database));
b.UseSetting("SQLInsight:Web:Authentication", "None");
});
}
public async Task DisposeAsync()
{
_agentKey.Dispose();
await _factory.DisposeAsync();
await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
}
private Task PublishAgentKeyAsync() => _repository.PublishAgentKeyAsync(new AgentKeyRow
{
KeyId = "agent-test", PublicKey = _agentKey.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"NT SERVICE\SQLInsight Agent",
Domain = "FIRMA", CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow,
});
private HttpClient Client() => _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
/// <summary>Posts a form the way the browser does: with the antiforgery token from <paramref name="page"/>.</summary>
private static async Task<HttpResponseMessage> PostAsync(HttpClient client, string page, string action, Dictionary<string, string> fields, params (string Name, string Value)[] repeated)
{
var html = await client.GetStringAsync(page);
var values = new List<KeyValuePair<string, string>> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) };
values.AddRange(fields);
values.AddRange(repeated.Select(r => new KeyValuePair<string, string>(r.Name, r.Value)));
using var content = new FormUrlEncodedContent(values);
return await client.PostAsync(action, content);
}
private static int IdFrom(HttpResponseMessage response) =>
int.Parse(Regex.Match(response.Headers.Location!.OriginalString, @"/opsaetning/instanser/(\d+)").Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
[Fact]
public async Task Overview_marks_paused_servers_and_leaves_out_removed_ones_and_so_do_the_alerts()
{
var now = DateTimeOffset.UtcNow;
var paused = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("paused-sql", "sql05", null, "Windows", null, null, null, "Mandatory", false, false), "anna", now);
var removed = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("removed-sql", "sql06", null, "Windows", null, null, null, "Mandatory", false, false), "anna", now);
await _repository.RecordInstanceErrorAsync("paused-sql", "kan ikke nås", now);
await _repository.RecordInstanceErrorAsync("removed-sql", "kan ikke nås", now);
await _repository.SetInstancePausedAsync(paused, true, "anna", now);
await _repository.RemoveConfiguredInstanceAsync(removed, "anna", now);
var home = await Client().GetStringAsync("/");
var metrics = await Client().GetStringAsync("/metrics");
Assert.Contains("paused-sql", home, StringComparison.Ordinal);
Assert.Contains("Sat på pause", home, StringComparison.Ordinal);
Assert.DoesNotContain("removed-sql", home, StringComparison.Ordinal);
Assert.Contains("sqlinsight_instance_paused{instance=\"paused-sql\"} 1", metrics, StringComparison.Ordinal);
Assert.DoesNotContain("sqlinsight_instance_up{instance=\"paused-sql\"}", metrics, StringComparison.Ordinal);
Assert.DoesNotContain("removed-sql", metrics, StringComparison.Ordinal);
// The history is still there.
Assert.Contains("fjernet", await Client().GetStringAsync("/instanser/removed-sql"), StringComparison.Ordinal);
}
[Fact]
public async Task Page_lists_servers_from_the_configuration_file_and_the_web_and_the_menu_links_to_it()
{
await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow);
await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("ax-sql", "ax01\\AX30", null, "Windows", null, null, null, "Mandatory", false, false), "anna", DateTimeOffset.UtcNow);
var page = await Client().GetStringAsync("/opsaetning/instanser");
var home = await Client().GetStringAsync("/");
Assert.Contains("prod-sql01", page, StringComparison.Ordinal);
Assert.Contains("fra konfigurationsfil", page, StringComparison.Ordinal);
Assert.Contains(@"ax01\AX30", page, StringComparison.Ordinal);
Assert.Contains("Alle (også nye)", page, StringComparison.Ordinal);
Assert.Contains("Agenten har ikke meldt sig endnu", page, StringComparison.Ordinal);
Assert.Contains("href=\"/opsaetning/instanser\"", home, StringComparison.Ordinal);
Assert.Contains("Servere og databaser", home, StringComparison.Ordinal);
}
[Fact]
public async Task Admin_adds_a_server_and_the_agent_is_asked_to_test_it_and_find_its_databases()
{
var client = Client();
var posted = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", new()
{
["server"] = @"sql01.firma.local\PROD", ["port"] = "1450", ["authentication"] = "Windows", ["encryption"] = "Mandatory",
});
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
var id = IdFrom(posted);
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.Equal((@"sql01.firma.local\PROD", 1450, "Windows", false), (row.Name, row.Port, row.Authentication, row.MonitorNewDatabases));
Assert.Equal(AgentRequestStatus.Pending, (await _repository.GetLatestAgentRequestAsync(id, AgentRequestKind.ConnectionTest))!.Status);
Assert.NotNull(await _repository.GetLatestAgentRequestAsync(id, AgentRequestKind.DatabaseDiscovery));
var waiting = await client.GetStringAsync($"/opsaetning/instanser/{id}?tilfoejet=1");
Assert.Contains("Venter på agenten", waiting, StringComparison.Ordinal);
Assert.Contains("http-equiv=\"refresh\"", waiting, StringComparison.Ordinal);
// The agent has published who it is: the script is for the computer account it arrives as on another server.
await PublishAgentKeyAsync();
var page = await client.GetStringAsync($"/opsaetning/instanser/{id}");
Assert.Contains(@"SET @LoginName = N&#x27;FIRMA\AGENT01$&#x27;;", page, StringComparison.Ordinal);
Assert.Contains(@"CREATE LOGIN [FIRMA\AGENT01$] FROM WINDOWS", page, StringComparison.Ordinal);
var duplicate = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", new() { ["server"] = @"sql01.firma.local\PROD", ["authentication"] = "Windows" });
Assert.Contains("bruges allerede", Uri.UnescapeDataString(duplicate.Headers.Location!.OriginalString), StringComparison.Ordinal);
}
[Fact]
public async Task A_sql_login_password_is_sealed_for_the_agent_and_never_shown_again()
{
var client = Client();
var fields = new Dictionary<string, string>
{
["server"] = "sql02", ["authentication"] = "SqlLogin", ["userName"] = "sqlinsight", ["password"] = Password, ["trustServerCertificate"] = "true",
};
var withoutAgent = await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", fields);
Assert.Contains("Agenten har ikke meldt sig endnu", Uri.UnescapeDataString(withoutAgent.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
await PublishAgentKeyAsync();
var id = IdFrom(await PostAsync(client, "/opsaetning/instanser", "/opsaetning/instanser/tilfoej", fields));
var row = (await _repository.GetConfiguredInstanceAsync(id))!;
Assert.True(row.HasPassword);
Assert.Equal(Password, CredentialEnvelope.Open(row.PasswordCipher!, "agent-test", _agentKey, CredentialEnvelope.Context("sql02", "sqlinsight")));
var page = await client.GetStringAsync($"/opsaetning/instanser/{id}");
var api = await client.GetStringAsync($"/api/v1/monitored-instances/{id}");
Assert.DoesNotContain(Password, page, StringComparison.Ordinal);
Assert.Contains("adgangskode gemt krypteret", page, StringComparison.Ordinal);
Assert.DoesNotContain(Password, api, StringComparison.Ordinal);
Assert.DoesNotContain("cipher", api, StringComparison.OrdinalIgnoreCase);
Assert.True(JsonDocument.Parse(api).RootElement.GetProperty("hasPassword").GetBoolean());
// Another server without the password again is refused: the password is sealed to sql02.
var moved = await PostAsync(client, $"/opsaetning/instanser/{id}", $"/opsaetning/instanser/{id}/forbindelse", new()
{
["server"] = "evil01", ["authentication"] = "SqlLogin", ["userName"] = "sqlinsight",
});
Assert.Contains("Indtast adgangskoden igen", Uri.UnescapeDataString(moved.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Equal("sql02", (await _repository.GetConfiguredInstanceAsync(id))!.Server);
}
[Fact]
public async Task Connection_test_shows_the_missing_rights_and_the_script_for_the_login_the_agent_used()
{
var id = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("prod", "sql01", null, "Windows", null, null, null, "Mandatory", false, false), null, DateTimeOffset.UtcNow);
var request = await _repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, "anna", DateTimeOffset.UtcNow);
await _repository.ClaimAgentRequestAsync(DateTimeOffset.UtcNow);
await _repository.CompleteAgentRequestAsync(request, true, AgentRequestJson.Serialize(new ConnectionTestResult
{
Connected = true, Provider = "SqlClient", ProductVersion = "16.0.4135.4", Release = "SQL Server 2022", Edition = "Standard Edition", ServerName = "SQL01",
Login = @"FIRMA\AGENT01$", PermissionsChecked = true,
Permissions =
[
new PermissionStatus("VIEW SERVER PERFORMANCE STATE", false, true, "DMV'er"),
new PermissionStatus("VIEW ANY DEFINITION", true, true, "Metadata"),
new PermissionStatus("ALTER TRACE", false, false, "Valgfri"),
],
}), null, DateTimeOffset.UtcNow);
var page = await Client().GetStringAsync($"/opsaetning/instanser/{id}");
var api = JsonDocument.Parse(await Client().GetStringAsync($"/api/v1/monitored-instances/{id}/connection-test")).RootElement;
Assert.Contains("Agenten mangler 1 rettighed", page, StringComparison.Ordinal);
Assert.Contains("VIEW SERVER PERFORMANCE STATE", page, StringComparison.Ordinal);
Assert.Contains("Valgfri, mangler", page, StringComparison.Ordinal);
Assert.Contains(@"SET @LoginName = N&#x27;FIRMA\AGENT01$&#x27;;", page, StringComparison.Ordinal);
Assert.DoesNotContain("http-equiv=\"refresh\"", page, StringComparison.Ordinal);
Assert.Equal(["VIEW SERVER PERFORMANCE STATE"], api.GetProperty("missingPermissions").EnumerateArray().Select(e => e.GetString()));
Assert.True(api.GetProperty("request").GetProperty("result").GetProperty("connected").GetBoolean());
}
[Fact]
public async Task Databases_and_ax_findings_to_the_hub_are_chosen_with_the_form()
{
var id = await _repository.AddConfiguredInstanceAsync(new NewConfiguredInstance("ax", "ax01", null, "Windows", null, null, null, "Mandatory", false, false), null, DateTimeOffset.UtcNow);
await _repository.SaveDiscoveredDatabasesAsync(id,
[
new DiscoveredDatabase { Name = "master", IsSystem = true, HasAccess = true, State = "ONLINE" },
new DiscoveredDatabase { Name = "AX30", HasAccess = true, State = "ONLINE", IsDynamicsAx = true, DynamicsAxVersion = "3.0", SizeMb = 20480, RecoveryModel = "FULL", CompatibilityLevel = 90 },
new DiscoveredDatabase { Name = "Sales", HasAccess = true, State = "ONLINE" },
], DateTimeOffset.UtcNow);
var page = $"/opsaetning/instanser/{id}";
var html = await Client().GetStringAsync(page);
Assert.Contains("Ja, AX 3.0", html, StringComparison.Ordinal);
Assert.Contains("20,0 GB", html, StringComparison.Ordinal);
var posted = await PostAsync(Client(), page, page + "/databaser", new() { ["monitorNew"] = "true" }, ("monitor", "AX30"), ("ax", "AX30"), ("ax", "Sales"));
Assert.Equal(HttpStatusCode.Redirect, posted.StatusCode);
var databases = (await _repository.GetConfiguredDatabasesAsync(id)).ToDictionary(d => d.DatabaseName);
Assert.Equal((true, true), (databases["AX30"].Monitored, databases["AX30"].SendDynamicsAxToHub));
Assert.Equal((false, false), (databases["Sales"].Monitored, databases["Sales"].SendDynamicsAxToHub));
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases);
}
[Fact]
public async Task A_configuration_file_server_is_paused_here_but_removed_in_the_file()
{
var id = (await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow))["prod-sql01"];
var page = $"/opsaetning/instanser/{id}";
var removed = await PostAsync(Client(), page, page + "/fjern", []);
var paused = await PostAsync(Client(), page, page + "/pause", new() { ["paused"] = "true" });
Assert.Contains("konfigurationsfil", Uri.UnescapeDataString(removed.Headers.Location!.OriginalString), StringComparison.Ordinal);
Assert.Equal($"{page}?pause=1", paused.Headers.Location!.OriginalString);
Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.Paused);
Assert.DoesNotContain("Fjern serveren", await Client().GetStringAsync(page), StringComparison.Ordinal);
}
[Fact]
public async Task A_form_without_the_antiforgery_token_is_refused()
{
using var content = new FormUrlEncodedContent(new Dictionary<string, string> { ["server"] = "sql09" });
var response = await Client().PostAsync("/opsaetning/instanser/tilfoej", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(await _repository.GetConfiguredInstancesAsync());
}
[Fact]
public void Only_admin_groups_and_config_write_tokens_may_change_the_configuration()
{
var windows = new WebOptions { Authentication = "Windows" };
windows.AdminGroups.Add(@"FIRMA\SQL-Admins");
static ClaimsPrincipal User(params Claim[] claims) => new(new ClaimsIdentity(claims, "Negotiate", ClaimTypes.Name, ClaimTypes.Role));
Assert.True(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Readers")), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), new WebOptions { Authentication = "Windows" }));
Assert.True(InstanceSetup.MayChange(User(), new WebOptions { Authentication = "None" }));
Assert.True(InstanceSetup.MayChange(User(new Claim(ApiTokenHandler.ClaimType, "itsm"), new Claim(ApiTokenHandler.ScopeClaimType, ApiToken.ConfigWrite)), windows));
Assert.False(InstanceSetup.MayChange(User(new Claim(ApiTokenHandler.ClaimType, "grafana"), new Claim(ApiTokenHandler.ScopeClaimType, ApiToken.Read),
new Claim(ClaimTypes.Role, @"FIRMA\SQL-Admins")), windows));
}
[Fact]
public void Without_admin_groups_the_local_administrators_may_change_the_configuration()
{
static ClaimsPrincipal User(params Claim[] claims) => new(new ClaimsIdentity(claims, "Negotiate", ClaimTypes.Name, ClaimTypes.Role));
var empty = new WebOptions { Authentication = "Windows" };
var administrator = User(new Claim(ClaimTypes.GroupSid, WebOptions.BuiltinAdministratorsSid));
// A remote Windows login carries the full token: Administrators is a group SID.
Assert.True(InstanceSetup.MayChange(administrator, empty));
// A UAC-filtered token has it only as deny-only, and that does not count.
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.DenyOnlySid, WebOptions.BuiltinAdministratorsSid)), empty));
Assert.False(InstanceSetup.MayChange(User(new Claim(ClaimTypes.GroupSid, "S-1-5-32-545")), empty));
Assert.False(InstanceSetup.MayChange(new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.GroupSid, WebOptions.BuiltinAdministratorsSid)])), empty));
// Once groups are set they decide alone; ADMIN_GROUPS from the installer may list several separated by ';'.
var groups = new WebOptions { Authentication = "Windows" };
groups.AdminGroups.Add(@"FIRMA\SQL-Admins; FIRMA\DBA");
Assert.False(InstanceSetup.MayChange(administrator, groups));
Assert.True(InstanceSetup.MayChange(User(new Claim(ClaimTypes.Role, @"FIRMA\DBA")), groups));
Assert.Equal([@"FIRMA\SQL-Admins", @"FIRMA\DBA"], groups.AdminGroupNames);
}
}