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>
299 lines
18 KiB
C#
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'FIRMA\AGENT01$';", 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'FIRMA\AGENT01$';", 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);
|
|
}
|
|
}
|
|
|