diff --git a/src/SQLInsight.Web/Api/ApiTokens.cs b/src/SQLInsight.Web/Api/ApiTokens.cs index bd670d4..242b409 100644 --- a/src/SQLInsight.Web/Api/ApiTokens.cs +++ b/src/SQLInsight.Web/Api/ApiTokens.cs @@ -16,7 +16,10 @@ public sealed record ApiToken(string Name, string Hash, IReadOnlyList Sc /// Create and close support cases. public const string SupportWrite = "support:write"; - public static readonly IReadOnlyList KnownScopes = [Read, SupportWrite]; + /// Change which instances and databases are monitored, and start connection tests and discovery. + public const string ConfigWrite = "config:write"; + + public static readonly IReadOnlyList KnownScopes = [Read, SupportWrite, ConfigWrite]; /// /// SQLInsight:Web:ApiTokens: either the old form "name": "sha256:…" (read only) or diff --git a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs new file mode 100644 index 0000000..fd7b9e4 --- /dev/null +++ b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs @@ -0,0 +1,338 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; + +namespace SQLInsight.Web.Api; + +/// Body of POST /api/v1/monitored-instances and PUT …/{id}/connection. The password is write-only. +public sealed record MonitoredInstanceBody( + string? Name, + string? Server, + int? Port, + string? Authentication, + string? UserName, + string? Password, + string? Encryption, + bool? TrustServerCertificate, + bool? MonitorNewDatabases); + +/// Body of PATCH /api/v1/monitored-instances/{id}. +public sealed record MonitoredInstancePatch(bool? Paused, bool? MonitorNewDatabases); + +/// One database's choice in PUT …/{id}/databases. Omitted values are left as they are. +public sealed record DatabaseChoiceBody(string? Name, bool? Monitored, bool? SendDynamicsAxToHub); + +/// Body of PUT /api/v1/monitored-instances/{id}/databases. +public sealed record DatabaseChoicesBody(bool? MonitorNewDatabases, IReadOnlyList? Databases); + +/// Body of POST /api/v1/instance-discovery. +public sealed record InstanceDiscoveryBody(string? Host); + +/// +/// Which instances and databases are monitored, through the API: everything the page Servere og databaser does. +/// Reading needs the scope read; changing needs config:write for tokens, or membership of SQLInsight:Web:AdminGroups +/// for Windows users. Connection tests and discovery are queued for the agent (202 Accepted with a request id to +/// follow at /api/v1/agent-requests/{id}). Passwords are write-only and never returned. +/// +public static class ConfigurationApiEndpoints +{ + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead(); + const string write = " Needs scope config:write for tokens, or SQLInsight:Web:AdminGroups for Windows users."; + + api.MapGet("/monitored-instances", ListAsync).WithSummary("Overvågede instanser / Monitored instances (configuration)"); + api.MapPost("/monitored-instances", AddAsync) + .WithSummary("Tilføj en instans / Add an instance") + .WithDescription("201 Created. The agent tests the connection and lists the databases at once (connectionTestRequestId)." + write); + api.MapGet("/monitored-instances/{id:int}", GetAsync).WithSummary("Én overvåget instans / One monitored instance"); + api.MapPatch("/monitored-instances/{id:int}", PatchAsync) + .WithSummary("Sæt på pause, genoptag, overvåg nye databaser / Pause, resume, monitor new databases").WithDescription(write.Trim()); + api.MapPut("/monitored-instances/{id:int}/connection", UpdateConnectionAsync) + .WithSummary("Ret forbindelsen / Change the connection") + .WithDescription("Instances added in SQLInsight Web only (409 for the configuration file). The password must be given again when server, port or login change." + write); + api.MapDelete("/monitored-instances/{id:int}", RemoveAsync) + .WithSummary("Stop overvågningen af en instans / Remove an instance") + .WithDescription("204. Collected data is kept. 409 for an instance from the agent's configuration file (pause it instead)." + write); + api.MapPost("/monitored-instances/{id:int}/connection-test", (int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) => + QueueAsync(id, AgentRequestKind.ConnectionTest, http, repository, options, ct)) + .WithSummary("Test forbindelsen fra agenten / Test the connection from the agent").WithDescription("202 Accepted with requestId." + write); + api.MapGet("/monitored-instances/{id:int}/connection-test", LatestTestAsync) + .WithSummary("Seneste forbindelsestest med manglende rettigheder / Latest connection test and missing permissions"); + api.MapPost("/monitored-instances/{id:int}/discovery", (int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) => + QueueAsync(id, AgentRequestKind.DatabaseDiscovery, http, repository, options, ct)) + .WithSummary("Søg efter databaser / Discover databases").WithDescription("202 Accepted with requestId; the result is in GET …/databases." + write); + api.MapGet("/monitored-instances/{id:int}/databases", DatabasesAsync).WithSummary("Fundne databaser og valg / Discovered databases and choices"); + api.MapPut("/monitored-instances/{id:int}/databases", ChooseAsync) + .WithSummary("Vælg databaser og AX-fund til hubben / Choose databases and AX findings to the hub") + .WithDescription("Databases not named keep their choice. 422 with the names that were not found." + write); + api.MapGet("/monitored-instances/{id:int}/grant-script", GrantScriptAsync) + .WithSummary("Least-privilege GRANT-script til agentens login / Least-privilege GRANT script for the agent's login"); + api.MapPost("/instance-discovery", FindAsync) + .WithSummary("Find SQL Server-instanser på en computer / Find SQL Server instances on a computer") + .WithDescription("202 Accepted with requestId. The agent asks SQL Server Browser (UDP 1434) and, on its own computer, the registry." + write); + api.MapGet("/agent-requests/{id:long}", RequestAsync).WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request"); + } + + private static IResult Forbidden() => Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Ikke tilladt", + detail: "Ændringer kræver scope \"config:write\" (API-token) eller medlemskab af en gruppe i SQLInsight:Web:AdminGroups."); + + private static IResult Invalid(string detail) => Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig", detail: detail); + + private static object Json(ConfiguredInstanceRow i) => new + { + Id = i.ConfiguredInstanceId, + i.Name, + i.Server, + i.Port, + i.DataSource, + i.Authentication, + i.UserName, + i.HasPassword, + i.Encryption, + i.TrustServerCertificate, + Source = i.FromConfigFile ? "configFile" : "web", + i.Paused, + i.MonitorNewDatabases, + i.CreatedAt, + i.CreatedBy, + i.UpdatedAt, + i.UpdatedBy, + i.LastDiscoveryAt, + i.LastDiscoveryError, + i.CredentialError, + }; + + private static object Json(ConfiguredDatabaseRow d) => new + { + Name = d.DatabaseName, + d.Monitored, + d.SendDynamicsAxToHub, + d.State, + d.SizeMb, + d.RecoveryModel, + d.CompatibilityLevel, + d.IsReadOnly, + d.IsSystem, + d.IsSnapshot, + d.HasAccess, + d.IsDynamicsAx, + d.DynamicsAxVersion, + d.Gone, + d.FirstSeenAt, + d.LastSeenAt, + d.SelectionChangedAt, + d.SelectionChangedBy, + }; + + private static object Json(AgentRequestRow r) => new + { + Id = r.RequestId, + r.Kind, + InstanceId = r.ConfiguredInstanceId, + r.Status, + r.RequestedBy, + r.RequestedAt, + r.StartedAt, + r.CompletedAt, + r.Error, + Result = r.Result is { Length: > 0 } result ? JsonDocument.Parse(result).RootElement.Clone() : (JsonElement?)null, + }; + + private static InstanceInput Input(MonitoredInstanceBody body) => new(body.Name, body.Server, body.Port, body.Authentication, body.UserName, body.Password, + body.Encryption, body.TrustServerCertificate ?? false, body.MonitorNewDatabases ?? false); + + private static async Task ListAsync(InsightRepository repository, CancellationToken ct) => + Results.Ok((await repository.GetConfiguredInstancesAsync(ct).ConfigureAwait(false)).Select(Json)); + + private static async Task GetAsync(int id, InsightRepository repository, CancellationToken ct) => + await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row)) : Results.NotFound(); + + private static async Task AddAsync(MonitoredInstanceBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await InstanceSetup.AddAsync(repository, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.Error is { } error) + { + return Invalid(error); + } + + var row = (await repository.GetConfiguredInstanceAsync(outcome.Id!.Value, ct).ConfigureAwait(false))!; + return Results.Created($"/api/v1/monitored-instances/{row.ConfiguredInstanceId}", new { Instance = Json(row), ConnectionTestRequestId = outcome.RequestId }); + } + + private static async Task PatchAsync(int id, MonitoredInstancePatch body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var by = http.User.Identity?.Name; + if (body.Paused is { } paused) + { + await repository.SetInstancePausedAsync(id, paused, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + } + + if (body.MonitorNewDatabases is { } monitorNew) + { + await repository.SetMonitorNewDatabasesAsync(id, monitorNew, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); + } + + return Results.Ok(Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!)); + } + + private static async Task UpdateConnectionAsync(int id, MonitoredInstanceBody body, HttpContext http, InsightRepository repository, IOptions options, + CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await InstanceSetup.UpdateAsync(repository, id, Input(body), http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.NotFound) + { + return Results.NotFound(); + } + + if (outcome.Error is { } error) + { + return (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))?.FromConfigFile == true + ? Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Fra konfigurationsfilen", detail: error) + : Invalid(error); + } + + return Results.Ok(new { Instance = Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!), ConnectionTestRequestId = outcome.RequestId }); + } + + private static async Task RemoveAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + return await repository.RemoveConfiguredInstanceAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, ct).ConfigureAwait(false) switch + { + ConfigurationChangeOutcome.Changed => Results.NoContent(), + ConfigurationChangeOutcome.FromConfigFile => Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Fra konfigurationsfilen", + detail: "Instansen kommer fra agentens konfigurationsfil og fjernes dér. Den kan sættes på pause med PATCH."), + _ => Results.NotFound(), + }; + } + + private static async Task QueueAsync(int id, string kind, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await InstanceSetup.RequestAsync(repository, id, kind, http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.NotFound) + { + return Results.NotFound(); + } + + var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!; + return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request)); + } + + private static async Task LatestTestAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + var test = await repository.GetLatestAgentRequestAsync(id, AgentRequestKind.ConnectionTest, ct).ConfigureAwait(false); + if (test is null) + { + return Results.NotFound(); + } + + var result = AgentRequestJson.Deserialize(test.Result); + return Results.Ok(new { Request = Json(test), MissingPermissions = result?.MissingRequired.Select(p => p.Name).ToList() ?? [] }); + } + + private static async Task DatabasesAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + + return Results.Ok((await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).Select(Json)); + } + + private static async Task ChooseAsync(int id, DatabaseChoicesBody body, HttpContext http, InsightRepository repository, IOptions options, + CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var known = (await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).ToDictionary(d => d.DatabaseName, StringComparer.OrdinalIgnoreCase); + var requested = body.Databases ?? []; + if (requested.Any(d => string.IsNullOrWhiteSpace(d.Name))) + { + return Invalid("Hver database skal have et navn."); + } + + var unknown = requested.Where(d => !known.ContainsKey(d.Name!)).Select(d => d.Name!).ToList(); + if (unknown.Count > 0) + { + return Invalid("Agenten har ikke fundet disse databaser: " + string.Join(", ", unknown) + ". Start en søgning (POST …/discovery), og prøv igen."); + } + + var choices = requested.Select(d => (Body: d, Current: known[d.Name!])) + .Select(x => new DatabaseChoice(x.Current.DatabaseName, x.Body.Monitored ?? x.Current.Monitored, x.Body.SendDynamicsAxToHub ?? x.Current.SendDynamicsAxToHub)) + .ToList(); + var (_, found) = await InstanceSetup.ChooseDatabasesAsync(repository, id, choices, body.MonitorNewDatabases, http.User.Identity?.Name, ct).ConfigureAwait(false); + return found ? Results.Ok((await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).Select(Json)) : Results.NotFound(); + } + + private static async Task GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct) + { + if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is not { } row) + { + return Results.NotFound(); + } + + var (login, script, note) = await InstanceSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false); + return Results.Ok(new { Login = login, Script = script, Note = note }); + } + + private static async Task FindAsync(InstanceDiscoveryBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) + { + if (!InstanceSetup.MayChange(http.User, options.Value)) + { + return Forbidden(); + } + + var outcome = await InstanceSetup.FindInstancesAsync(repository, body.Host, http.User.Identity?.Name, ct).ConfigureAwait(false); + if (outcome.Error is { } error) + { + return Invalid(error); + } + + var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!; + return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request)); + } + + private static async Task RequestAsync(long id, InsightRepository repository, CancellationToken ct) => + await repository.GetAgentRequestAsync(id, ct).ConfigureAwait(false) is { } request ? Results.Ok(Json(request)) : Results.NotFound(); +} diff --git a/src/SQLInsight.Web/Components/Layout/MainNav.razor b/src/SQLInsight.Web/Components/Layout/MainNav.razor index e5a1bea..bb4117b 100644 --- a/src/SQLInsight.Web/Components/Layout/MainNav.razor +++ b/src/SQLInsight.Web/Components/Layout/MainNav.razor @@ -43,6 +43,7 @@ new("Overblik", "/"), new("Alle fund", "/fund", "/fund/"), new("Fejllog", "/vaelg/fejllog", "/fejllog"), + new("Servere og databaser", "/opsaetning/instanser", "/opsaetning/"), ]), new("analyse", "Analyse", "chart", [ diff --git a/src/SQLInsight.Web/Components/Pages/Home.razor b/src/SQLInsight.Web/Components/Pages/Home.razor index 4c3838b..01f5f6d 100644 --- a/src/SQLInsight.Web/Components/Pages/Home.razor +++ b/src/SQLInsight.Web/Components/Pages/Home.razor @@ -10,7 +10,7 @@ @if (_instances.Count == 0) {
-

Der er endnu ingen data. Tjek, at SQLInsight Agent kører og har instanser under SQLInsight:Instances i sin konfiguration.

+

Der er endnu ingen data. Tilføj en server under Servere og databaser, og tjek, at SQLInsight Agent kører.

} else diff --git a/src/SQLInsight.Web/Components/Pages/InstanceSetupDetail.razor b/src/SQLInsight.Web/Components/Pages/InstanceSetupDetail.razor new file mode 100644 index 0000000..e4718dd --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/InstanceSetupDetail.razor @@ -0,0 +1,376 @@ +@page "/opsaetning/instanser/{Id:int}" +@using SQLInsight.Core.Configuration +@using Microsoft.AspNetCore.Components.Forms +@inject InsightRepository Repository +@inject Display Display +@inject Microsoft.Extensions.Options.IOptions Options + +@if (_instance is null) +{ + Ukendt server · SQLInsight Pro +

Serveren findes ikke

+

Tilbage til servere og databaser

+ return; +} + +@_instance.Name · Servere og databaser · SQLInsight Pro +@if (_test is { IsFinished: false } || _discovery is { IsFinished: false }) +{ + @* The agent works on it: look again in two seconds (no script needed). *@ + +} + +

Servere og databaser › @_instance.Name

+

@_instance.Name

+

+ @_instance.DataSource · + @(_instance.FromConfigFile ? "fra konfigurationsfil" : "tilføjet i SQLInsight Web") · + @if (_instance.Paused) + { + På pause + } + else + { + Overvåges + } + @if (!_instance.Paused) + { + · Se fund + } +

+ +@if (Tilfoejet == 1) +{ +

Serveren er tilføjet. Agenten tester nu forbindelsen og søger efter databaser; resultatet vises herunder om et øjeblik.

+} +@if (Gemt == 1) +{ +

Forbindelsen er gemt, og agenten tester den igen.

+} +@if (Pause == 1) +{ +

Overvågningen er sat på pause. Agenten stopper inden for et minut; det indsamlede er bevaret.

+} +@if (Genoptaget == 1) +{ +

Overvågningen er genoptaget.

+} +@if (Databaser == 1) +{ +

Valget er gemt. Agenten bruger det fra næste runde (inden for et minut).

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} +@if (_instance.CredentialError is { } credentialError) +{ +
Agenten kan ikke bruge den gemte adgangskode.

@credentialError

+} + +
+

Forbindelse

+ + + + + + + +
Server@_instance.Server
Port@(_instance.Port?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "standard (eller via SQL Server Browser)")
Godkendelse@InstanceSetup.AuthenticationText(_instance)@(_instance.Authentication == "SqlLogin" ? (_instance.HasPassword ? " · adgangskode gemt krypteret" : " · ingen adgangskode") : string.Empty)
Kryptering@(_instance.Encryption == "Optional" ? "Valgfri" : "Påkrævet")@(_instance.TrustServerCertificate ? " · stoler på servercertifikatet" : string.Empty)
Oprettet@Display.Time(_instance.CreatedAt)@(_instance.CreatedBy is { } createdBy ? " af " + createdBy : string.Empty)
Senest ændret@Display.Time(_instance.UpdatedAt)@(_instance.UpdatedBy is { } updatedBy ? " af " + updatedBy : string.Empty)
+ @if (_instance.FromConfigFile) + { +

Forbindelsen står i agentens konfigurationsfil (SQLInsight:Instances) og ændres eller fjernes dér. Her kan den sættes på pause, og databaserne vælges.

+ } + + @if (_mayChange) + { + + @if (!_instance.FromConfigFile) + { +
+ Ret forbindelsen +
+ +
+ + +
+
+ + + +
+
+ + +
+

Ændres server, port eller login, skal adgangskoden indtastes igen: den er krypteret til netop den server og det login.

+ + +
+ } + } + +

Seneste forbindelsestest

+ @if (_test is null) + { +

Der er ikke testet endnu.

+ } + else + { +

@InstanceSetup.StatusText(_test, DateTime.UtcNow) · bestilt @Display.Time(_test.RequestedAt)@(_test.RequestedBy is { } who ? " af " + who : string.Empty)@(_test.CompletedAt is { } completed ? " · udført " + Display.Time(completed) + " af agenten" : string.Empty)

+ @if (_test.Error is { } testError) + { +
Testen kunne ikke udføres.

@testError

+ } + @if (_testResult is { Connected: false } failed) + { +
+ Agenten kunne ikke forbinde. +
@failed.Error
+ @if (failed.Hint is { } hint) + { +

@hint

+ } +
+ } + else if (_testResult is { } ok) + { + + + + + +
Version@ok.Release (@ok.ProductVersion) · @ok.Edition
Servernavn@ok.ServerName
Forbundet med@ok.Provider@(ok.UsedLegacyFallback ? " (ældre ODBC-driver)" : string.Empty)
Agentens login@ok.Login
+ @foreach (var note in ok.Notes) + { +

@note

+ } + @if (ok.PermissionsChecked) + { + var missing = ok.MissingRequired.Count(); + @if (missing == 0) + { +

Agenten har alle de rettigheder, den skal bruge.

+ } + else + { +
Agenten mangler @missing @(missing == 1 ? "rettighed" : "rettigheder"). Kør GRANT-scriptet nedenfor som sysadmin på instansen, og test igen.
+ } + + + + @foreach (var p in ok.Permissions.OrderBy(p => p.Granted).ThenByDescending(p => p.Required)) + { + + + + + + } + +
RettighedStatusBruges til
@p.Name + @if (p.Granted) + { + Har + } + else if (p.Required) + { + Mangler + } + else + { + Valgfri, mangler + } + @p.Purpose
+ } + } + } +
+ +
+

Rettigheder til agentens login

+

Agenten skal aldrig være sysadmin. Scriptet giver præcis de læserettigheder, overvågningen bruger (DMV'er, metadata, backup- og jobhistorik i msdb), og ingen adgang til data. Det er det samme som db/permissions/grant-least-privilege.sql, udfyldt med agentens login. SQLInsight kører det ikke selv: en sysadmin kører det på instansen, og scriptet kan køres igen uden skade.

+ @if (_grant.Script is { } script) + { +

Login: @_grant.Login

+ @if (_grant.Note is { } grantNote) + { +

@grantNote

+ } +
+ +
@script
+
+ } + else + { +

@_grant.Note

+ } +
+ +
+

Databaser

+

Agenten søger efter alle de databaser, den kan se, når serveren tilføjes og derefter hver time. Vælg, hvilke der skal overvåges. Instansdækkende målinger (ventetyper, CPU, blokeringer, job, fejllog …) gælder altid hele instansen; valget styrer de databasespecifikke tjek: indstillinger, backup, indexes, Query Store og community-regler. Systemdatabaserne overvåges altid.

+

+ @if (_instance.LastDiscoveryAt is { } discovered) + { + Senest søgt @Display.Time(discovered). + } + else + { + Der er ikke søgt endnu. + } + @if (_discovery is { IsFinished: false } running) + { + @InstanceSetup.StatusText(running, DateTime.UtcNow) + } +

+ @if (_instance.LastDiscoveryError is { } discoveryError) + { +
Seneste søgning fejlede.

@discoveryError

+ } + @if (_mayChange) + { +
+ } + + @if (_databases.Count > 0) + { +
+ +

+ + · ellers vises nye databaser her, og overvåges først, når de vælges. +

+ + + + + + @foreach (var d in _databases) + { + + + + + + + + + + + } + +
OvervågDatabaseTilstandStørrelseRecovery modelCompat. levelDynamics AXAX-fund til SQLInsight Hub
+ @if (d.IsSystem) + { + altid + } + else if (!d.Gone) + { + + } + @d.DatabaseName@(d.IsSnapshot ? " (snapshot)" : string.Empty) + @(d.Gone ? "findes ikke længere" : StateText(d.State))@(d.IsReadOnly ? ", skrivebeskyttet" : string.Empty) + @if (!d.Gone && !d.HasAccess && !d.IsSystem) + { +
Agenten kan ikke åbne databasen; kør GRANT-scriptet.
+ } +
@(d.SizeMb is { } mb ? Size(mb) : "–")@(d.RecoveryModel ?? "–")@(d.CompatibilityLevel?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–")@(d.IsDynamicsAx ? "Ja, AX " + d.DynamicsAxVersion : string.Empty) + @if (d.IsDynamicsAx && !d.Gone) + { + + } +
+

AX-fund sendes som standard ikke til SQLInsight Hub. Slås det til for en database, sendes AX-fundene derfra, når deling med hubben er slået til (se System).

+ @if (_mayChange) + { + + } + + } +
+ + +@code { + [Parameter] public int Id { get; set; } + [CascadingParameter] private HttpContext? Http { get; set; } + [SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; } + [SupplyParameterFromQuery(Name = "tilfoejet")] public int? Tilfoejet { get; set; } + [SupplyParameterFromQuery(Name = "gemt")] public int? Gemt { get; set; } + [SupplyParameterFromQuery(Name = "pause")] public int? Pause { get; set; } + [SupplyParameterFromQuery(Name = "genoptaget")] public int? Genoptaget { get; set; } + [SupplyParameterFromQuery(Name = "databaser")] public int? Databaser { get; set; } + + private ConfiguredInstanceRow? _instance; + private IReadOnlyList _databases = []; + private AgentRequestRow? _test; + private AgentRequestRow? _discovery; + private ConnectionTestResult? _testResult; + private (string? Login, string? Script, string? Note) _grant; + private bool _mayChange; + + protected override async Task OnInitializedAsync() + { + _instance = await Repository.GetConfiguredInstanceAsync(Id); + if (_instance is null) + { + if (Http is not null) + { + Http.Response.StatusCode = StatusCodes.Status404NotFound; + } + + return; + } + + _databases = await Repository.GetConfiguredDatabasesAsync(Id); + _test = await Repository.GetLatestAgentRequestAsync(Id, AgentRequestKind.ConnectionTest); + _discovery = await Repository.GetLatestAgentRequestAsync(Id, AgentRequestKind.DatabaseDiscovery); + _testResult = AgentRequestJson.Deserialize(_test?.Result); + _grant = await InstanceSetup.GrantScriptAsync(Repository, _instance, CancellationToken.None); + _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); + } + + private static string StateText(string? state) => state switch + { + "ONLINE" => "Online", + "OFFLINE" => "Offline", + "RESTORING" => "Gendannes", + "RECOVERING" => "Gendannes efter genstart", + "RECOVERY_PENDING" => "Afventer gendannelse", + "SUSPECT" => "Mistænkt (beskadiget)", + "EMERGENCY" => "Nødtilstand", + null => "–", + _ => state, + }; + + private static string Size(long megabytes) => megabytes >= 1024 + ? Display.Number(megabytes / 1024d, 1) + " GB" + : Display.Number(megabytes) + " MB"; +} diff --git a/src/SQLInsight.Web/Components/Pages/InstanceSetupPage.razor b/src/SQLInsight.Web/Components/Pages/InstanceSetupPage.razor new file mode 100644 index 0000000..f9c8fc9 --- /dev/null +++ b/src/SQLInsight.Web/Components/Pages/InstanceSetupPage.razor @@ -0,0 +1,212 @@ +@page "/opsaetning/instanser" +@using SQLInsight.Core.Configuration +@using Microsoft.AspNetCore.Components.Forms +@inject InsightRepository Repository +@inject Display Display +@inject Microsoft.Extensions.Options.IOptions Options + +Servere og databaser · SQLInsight Pro +@if (_search is { IsFinished: false }) +{ + +} + +

Servere og databaser

+

De SQL Server-instanser, SQLInsight overvåger. Tilføj en server, så finder agenten alle de databaser, den kan se, og I vælger, hvilke der skal overvåges. Forbindelsestest og søgning udføres af agenten med dens egen konto, præcis som overvågningen.

+ +@if (Fjernet == 1) +{ +

Serveren overvåges ikke længere. Det, der allerede er indsamlet, er bevaret.

+} +@if (!string.IsNullOrEmpty(Fejl)) +{ + +} +@if (!_mayChange) +{ +

+ Du kan se opsætningen, men kun administratorer kan ændre den. + @if (Options.Value.AdminGroups.Count == 0) + { + Der er ikke sat nogen administratorgruppe op endnu: tilføj en Windows-gruppe under SQLInsight:Web:AdminGroups i SQLInsight Webs appsettings.json. + } +

+} +@if (_agentKey is null) +{ +
+ Agenten har ikke meldt sig endnu. +

Forbindelsestest, søgning efter databaser og SQL-login-adgangskoder kræver, at SQLInsight Agent version 1.3 eller nyere kører mod samme repository.

+
+} + +
+

Overvågede servere

@_instances.Count(i => !i.Paused) aktive
+ @if (_instances.Count == 0) + { +

Der er ingen servere endnu. @(_mayChange ? "Tilføj den første nedenfor." : string.Empty)

+ } + else + { + + + + @foreach (var i in _instances) + { + + + + + + + + + } + +
NavnServerGodkendelseDatabaserStatusKilde
@i.Name@i.DataSource@InstanceSetup.AuthenticationText(i)@DatabaseSummary(i) + @if (i.CredentialError is not null) + { + Adgangskode skal indtastes igen + } + else if (i.Paused) + { + På pause + } + else + { + Overvåges + } + @(i.FromConfigFile ? "fra konfigurationsfil" : "SQLInsight Web")
+ } +
+ +@if (_mayChange) +{ +
+

Tilføj server

+
+ +
+ + + +
+
+ + + +
+
+ + + +
+

Adgangskoden krypteres med agentens offentlige nøgle, så kun agenten kan læse den. Den vises aldrig igen, heller ikke i API'et. Windows-godkendelse er at foretrække: så bruges agentens tjenestekonto, og der gemmes ingen adgangskode.

+ + +
+ +
+

Find instanser

+

Agenten spørger SQL Server Browser på computeren (UDP 1434) og, på sin egen computer, registreringsdatabasen. Instanser, der er skjult eller bag en firewall, findes ikke; dem kan I tilføje manuelt.

+
+ + + + + @if (_search is { } search) + { +

@InstanceSetup.StatusText(search, DateTime.UtcNow)

+ @if (search.Error is { } searchError) + { +

@searchError

+ } + @if (_found is { } found) + { + @foreach (var note in found.Notes) + { +

@note

+ } + @if (found.Instances.Count > 0) + { + + + + @foreach (var f in found.Instances) + { + + + + + + + + } + +
ServerVersionTCP-portFundet via
@f.Server@(f.IsClustered ? " (cluster)" : "")@(f.Version is { } v ? Display.Release(v) + " (" + v + ")" : "–")@(f.TcpPort?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "–")@f.Source + @if (_instances.Any(i => string.Equals(i.Server, f.Server, StringComparison.OrdinalIgnoreCase))) + { + Overvåges allerede + } + else + { + Tilføj + } +
+ } + } + } +
+} + +@code { + [CascadingParameter] private HttpContext? Http { get; set; } + [SupplyParameterFromQuery(Name = "fejl")] public string? Fejl { get; set; } + [SupplyParameterFromQuery(Name = "fjernet")] public int? Fjernet { get; set; } + [SupplyParameterFromQuery(Name = "server")] public string? Server { get; set; } + [SupplyParameterFromQuery(Name = "soegning")] public long? Soegning { get; set; } + + private IReadOnlyList _instances = []; + private ILookup _databases = Array.Empty().ToLookup(d => d.ConfiguredInstanceId); + private AgentKeyRow? _agentKey; + private AgentRequestRow? _search; + private InstanceDiscoveryResult? _found; + private bool _mayChange; + + protected override async Task OnInitializedAsync() + { + _instances = await Repository.GetConfiguredInstancesAsync(); + _databases = (await Repository.GetAllDatabaseChoicesAsync()).ToLookup(d => d.ConfiguredInstanceId); + _agentKey = await Repository.GetCurrentAgentKeyAsync(); + _mayChange = Http is not null && InstanceSetup.MayChange(Http.User, Options.Value); + if (Soegning is { } id && await Repository.GetAgentRequestAsync(id) is { Kind: AgentRequestKind.InstanceDiscovery } search) + { + _search = search; + _found = AgentRequestJson.Deserialize(search.Result); + } + } + + private string DatabaseSummary(ConfiguredInstanceRow instance) + { + var databases = _databases[instance.ConfiguredInstanceId].Where(d => !d.Gone && !d.IsSystem).ToList(); + if (databases.Count == 0) + { + return instance.MonitorNewDatabases ? "Alle (også nye)" : instance.LastDiscoveryAt is null ? "Ikke søgt endnu" : "Ingen brugerdatabaser"; + } + + var monitored = databases.Count(d => d.Monitored); + return monitored == databases.Count && instance.MonitorNewDatabases + ? $"Alle {databases.Count} (også nye)" + : $"{monitored} af {databases.Count}" + (instance.MonitorNewDatabases ? " (og nye)" : string.Empty); + } +} diff --git a/src/SQLInsight.Web/InstanceSetup.cs b/src/SQLInsight.Web/InstanceSetup.cs new file mode 100644 index 0000000..9e5d143 --- /dev/null +++ b/src/SQLInsight.Web/InstanceSetup.cs @@ -0,0 +1,299 @@ +using System.Security.Claims; +using Microsoft.Data.SqlClient; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; +using SQLInsight.Web.Api; + +namespace SQLInsight.Web; + +/// An instance as entered in the form or the API. The password is only used to seal it for the agent. +public sealed record InstanceInput( + string? Name, + string? Server, + int? Port, + string? Authentication, + string? UserName, + string? Password, + string? Encryption, + bool TrustServerCertificate, + bool MonitorNewDatabases); + +/// +/// Which servers and databases are monitored, as changed in SQLInsight Web or API v1: the same checks and rules for +/// both. The web UI only writes the configuration and queues requests in the repository; connection tests and +/// discovery run in the agent, as the agent's identity. A SQL login password is sealed with the agent's public key at +/// once, and never stored, shown or returned in clear text. +/// +public static class InstanceSetup +{ + public const string NotAdminText = + "Kun administratorer kan ændre, hvilke servere og databaser der overvåges. Bed en administrator om at tilføje jeres gruppe under SQLInsight:Web:AdminGroups."; + + public const string NoAgentKeyText = + "Agenten har ikke meldt sig endnu, så adgangskoden kan ikke krypteres til den. Start tjenesten SQLInsight Agent (version 1.3 eller nyere), og prøv igen om lidt."; + + /// A request older than this without an answer is shown as "agenten svarer ikke". + public static readonly TimeSpan AgentSilence = TimeSpan.FromMinutes(2); + + public sealed record Outcome(int? Id = null, long? RequestId = null, string? Error = null, bool NotFound = false); + + /// + /// Whether the user may change the configuration: an API token needs the scope config:write; a Windows user must be + /// in one of SQLInsight:Web:AdminGroups. With authentication off (development only) everyone may. + /// + public static bool MayChange(ClaimsPrincipal user, WebOptions options) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(options); + if (user.HasClaim(c => c.Type == ApiTokenHandler.ClaimType)) + { + return user.HasClaim(ApiTokenHandler.ScopeClaimType, ApiToken.ConfigWrite); + } + + if (string.Equals(options.Authentication, "None", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return options.AdminGroups.Any(user.IsInRole); + } + + private static string? Validate(InstanceInput input) + { + if (ServerAddress.Validate(input.Server, input.Port) is { } server) + { + return server; + } + + if (input.Authentication is not ("Windows" or "SqlLogin")) + { + return "Godkendelse skal være Windows (agentens tjenestekonto) eller SqlLogin."; + } + + if (input.Encryption is not (null or "Mandatory" or "Optional")) + { + return "Kryptering skal være Mandatory eller Optional."; + } + + if (input.Authentication == "SqlLogin" && (string.IsNullOrWhiteSpace(input.UserName) || input.UserName.Trim().Length > 128)) + { + return "Angiv SQL-login'et (højst 128 tegn)."; + } + + return input.Password?.Length > 256 ? "Adgangskoden er for lang." : null; + } + + /// Adds an instance and asks the agent to test the connection and list the databases. + public static async Task AddAsync(InsightRepository repository, InstanceInput input, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(input); + input = input with { Authentication = input.Authentication ?? "Windows", Encryption = input.Encryption ?? "Mandatory" }; + var name = string.IsNullOrWhiteSpace(input.Name) ? input.Server?.Trim() : input.Name.Trim(); + if ((Validate(input) ?? ServerAddress.ValidateName(name)) is { } invalid) + { + return new Outcome(Error: invalid); + } + + var dataSource = ServerAddress.DataSource(input.Server!, input.Port); + byte[]? cipher = null; + string? keyId = null; + if (input.Authentication == "SqlLogin") + { + if (string.IsNullOrEmpty(input.Password)) + { + return new Outcome(Error: "Angiv adgangskoden til SQL-login'et."); + } + + var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false); + if (key is null) + { + return new Outcome(Error: NoAgentKeyText); + } + + cipher = CredentialEnvelope.Seal(input.Password, key.KeyId, key.PublicKey, CredentialEnvelope.Context(dataSource, input.UserName)); + keyId = key.KeyId; + } + + int id; + try + { + id = await repository.AddConfiguredInstanceAsync(new NewConfiguredInstance(name!, input.Server!.Trim(), input.Port, input.Authentication!, + input.Authentication == "SqlLogin" ? input.UserName!.Trim() : null, cipher, keyId, input.Encryption!, input.TrustServerCertificate, + input.MonitorNewDatabases), by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + } + catch (SqlException ex) when (ex.Number is 2601 or 2627) + { + return new Outcome(Error: $"Navnet {name} bruges allerede af en anden instans. Vælg et andet navn."); + } + + var test = await repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + await repository.AddAgentRequestAsync(AgentRequestKind.DatabaseDiscovery, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + return new Outcome(id, test); + } + + /// + /// New connection details for an instance added in SQLInsight Web. The password must be typed again when the + /// server, port or login changes: it is sealed to them, so it cannot follow to another server unseen. + /// + public static async Task UpdateAsync(InsightRepository repository, int id, InstanceInput input, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(input); + var row = await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false); + if (row is null) + { + return new Outcome(NotFound: true); + } + + if (row.FromConfigFile) + { + return new Outcome(id, Error: "Instansen kommer fra agentens konfigurationsfil. Forbindelsen ændres dér."); + } + + input = input with { Authentication = input.Authentication ?? row.Authentication, Encryption = input.Encryption ?? row.Encryption }; + if (Validate(input) is { } invalid) + { + return new Outcome(id, Error: invalid); + } + + var dataSource = ServerAddress.DataSource(input.Server!, input.Port); + var userName = input.Authentication == "SqlLogin" ? input.UserName!.Trim() : null; + byte[]? cipher = null; + string? keyId = null; + if (input.Authentication == "SqlLogin") + { + var moved = !string.Equals(dataSource, row.DataSource, StringComparison.OrdinalIgnoreCase) || !string.Equals(userName, row.UserName, StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(input.Password) && (moved || !row.HasPassword)) + { + return new Outcome(id, Error: "Indtast adgangskoden igen: den følger ikke med, når server, port eller login ændres."); + } + + if (!string.IsNullOrEmpty(input.Password)) + { + var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false); + if (key is null) + { + return new Outcome(id, Error: NoAgentKeyText); + } + + cipher = CredentialEnvelope.Seal(input.Password, key.KeyId, key.PublicKey, CredentialEnvelope.Context(dataSource, userName)); + keyId = key.KeyId; + } + } + + var outcome = await repository.UpdateConfiguredInstanceAsync(id, new ConfiguredInstanceChange(input.Server!.Trim(), input.Port, input.Authentication!, userName, + cipher, keyId, ClearPassword: input.Authentication != "SqlLogin", input.Encryption!, input.TrustServerCertificate), by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + if (outcome != ConfigurationChangeOutcome.Changed) + { + return new Outcome(id, NotFound: outcome == ConfigurationChangeOutcome.NotFound, Error: "Instansen kunne ikke ændres."); + } + + var test =await repository.AddAgentRequestAsync(AgentRequestKind.ConnectionTest, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + return new Outcome(id, test); + } + + /// Asks the agent for a connection test or a database discovery of a configured instance. + public static async Task RequestAsync(InsightRepository repository, int id, string kind, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + if (await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) is null) + { + return new Outcome(NotFound: true); + } + + return new Outcome(id, await repository.AddAgentRequestAsync(kind, id, null, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false)); + } + + /// Asks the agent which SQL Server instances it can find on a computer (its own when empty). + public static async Task FindInstancesAsync(InsightRepository repository, string? host, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + host = string.IsNullOrWhiteSpace(host) ? null : host.Trim(); + if (host is not null && (host.Contains('\\', StringComparison.Ordinal) || ServerAddress.Validate(host, null) is not null)) + { + return new Outcome(Error: "Angiv et computernavn uden instans, fx sql01 eller sql01.firma.local."); + } + + return new Outcome(RequestId: await repository.AddAgentRequestAsync(AgentRequestKind.InstanceDiscovery, null, + AgentRequestJson.Serialize(new InstanceDiscoveryParameters(host)), by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false)); + } + + /// + /// Sets which of the found databases are monitored and send Dynamics AX findings to the hub, and whether new + /// databases are monitored automatically (null: unchanged). Databases not named keep their choice; the form names + /// them all, the API may name only some. + /// + public static async Task<(IReadOnlyList Unknown, bool Found)> ChooseDatabasesAsync(InsightRepository repository, int id, IReadOnlyCollection choices, + bool? monitorNew, string? by, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + if (await repository.GetConfiguredInstanceAsync(id, cancellationToken).ConfigureAwait(false) is null) + { + return ([], false); + } + + var unknown = await repository.SetDatabaseChoicesAsync(id, choices, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + if (monitorNew is { } automatic) + { + await repository.SetMonitorNewDatabasesAsync(id, automatic, by, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + } + + return (unknown, true); + } + + /// + /// The least-privilege GRANT script for the agent's login on an instance: the login the last connection test saw, + /// or else the one the agent's account will arrive as (computed from what the agent published), or the SQL login. + /// + public static async Task<(string? Login, string? Script, string? Note)> GrantScriptAsync(InsightRepository repository, ConfiguredInstanceRow row, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(repository); + ArgumentNullException.ThrowIfNull(row); + var test = await repository.GetLatestAgentRequestAsync(row.ConfiguredInstanceId, AgentRequestKind.ConnectionTest, cancellationToken).ConfigureAwait(false); + var seen = AgentRequestJson.Deserialize(test?.Result)?.Login; + var windows = row.Authentication == "Windows"; + string? login = seen; + string? note = null; + if (login is null && windows) + { + var key = await repository.GetCurrentAgentKeyAsync(cancellationToken).ConfigureAwait(false); + if (key is null) + { + return (null, null, "Agenten har ikke meldt sig endnu, så dens login kendes ikke. Start SQLInsight Agent, eller brug db/permissions/grant-least-privilege.sql og ret login'et."); + } + + login = ServiceAccounts.LoginFor(key.ServiceAccount, row.DataSource, key.MachineName, key.Domain ?? key.MachineName); + note = $"Beregnet ud fra agentens konto ({key.ServiceAccount} på {key.MachineName}). Forbindelsestesten viser det login, agenten faktisk ankommer som."; + } + + login ??= row.UserName; + return login is null ? (null, null, "Angiv et login.") : (login, GrantScript.For(login, windows && !login.Contains('@', StringComparison.Ordinal) && login.Contains('\\', StringComparison.Ordinal)), note); + } + + /// Danish text for a request's state. + public static string StatusText(AgentRequestRow request, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(request); + return request.Status switch + { + AgentRequestStatus.Pending when utcNow - request.RequestedAt > AgentSilence => + "Venter stadig på agenten. Kører tjenesten SQLInsight Agent (version 1.3 eller nyere)?", + AgentRequestStatus.Pending => "Venter på agenten …", + AgentRequestStatus.Running => "Agenten arbejder …", + AgentRequestStatus.Done => "Færdig", + _ => "Fejlede", + }; + } + + public static string AuthenticationText(ConfiguredInstanceRow row) + { + ArgumentNullException.ThrowIfNull(row); + return row.Authentication switch + { + "Windows" => "Windows (agentens tjenestekonto)", + "SqlLogin" => $"SQL-login {row.UserName}", + _ => row.Authentication, + }; + } +} diff --git a/src/SQLInsight.Web/InstanceSetupEndpoints.cs b/src/SQLInsight.Web/InstanceSetupEndpoints.cs new file mode 100644 index 0000000..68507e6 --- /dev/null +++ b/src/SQLInsight.Web/InstanceSetupEndpoints.cs @@ -0,0 +1,167 @@ +using System.Globalization; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.Extensions.Options; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; + +namespace SQLInsight.Web; + +/// +/// The forms on /opsaetning/instanser: each posts here, is checked (antiforgery, administrator), changes the +/// configuration in the repository or queues a request for the agent, and redirects back with a message. +/// +public static class InstanceSetupEndpoints +{ + public const string ListPage = "/opsaetning/instanser"; + + public static string DetailPage(int id) => ListPage + "/" + id.ToString(CultureInfo.InvariantCulture); + + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + app.MapPost(ListPage + "/tilfoej", AddAsync); + app.MapPost(ListPage + "/{id:int}/forbindelse", UpdateAsync); + app.MapPost(ListPage + "/{id:int}/pause", PauseAsync); + app.MapPost(ListPage + "/{id:int}/fjern", RemoveAsync); + app.MapPost(ListPage + "/{id:int}/test", (int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) => + RequestAsync(id, AgentRequestKind.ConnectionTest, http, repository, antiforgery, options)); + app.MapPost(ListPage + "/{id:int}/soeg", (int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) => + RequestAsync(id, AgentRequestKind.DatabaseDiscovery, http, repository, antiforgery, options)); + app.MapPost(ListPage + "/{id:int}/databaser", DatabasesAsync); + app.MapPost("/opsaetning/find-instanser", FindAsync); + } + + private static string WithMessage(string page, string key, string value) => + page + (page.Contains('?', StringComparison.Ordinal) ? "&" : "?") + key + "=" + Uri.EscapeDataString(value); + + /// Antiforgery and the administrator check; null when the request may go on. + private static async Task RefuseAsync(HttpContext http, IAntiforgery antiforgery, WebOptions options, string back) + { + if (!await antiforgery.IsRequestValidAsync(http).ConfigureAwait(false)) + { + return Results.BadRequest("Formularen er udløbet. Genindlæs siden, og prøv igen."); + } + + return InstanceSetup.MayChange(http.User, options) ? null : Results.Redirect(WithMessage(back, "fejl", InstanceSetup.NotAdminText)); + } + + private static InstanceInput Input(IFormCollection form) => new( + form["name"].ToString(), + form["server"].ToString(), + int.TryParse(form["port"].ToString(), NumberStyles.None, CultureInfo.InvariantCulture, out var port) ? port : null, + form["authentication"].ToString() is { Length: > 0 } authentication ? authentication : null, + form["userName"].ToString(), + form["password"].ToString(), + form["encryption"].ToString() is { Length: > 0 } encryption ? encryption : null, + form["trustServerCertificate"] == "true", + form["monitorNew"] == "true"); + + private static async Task AddAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ListPage).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + if (form["port"].ToString() is { Length: > 0 } port && !int.TryParse(port, NumberStyles.None, CultureInfo.InvariantCulture, out _)) + { + return Results.Redirect(WithMessage(ListPage, "fejl", "Porten skal være et tal.")); + } + + var outcome = await InstanceSetup.AddAsync(repository, Input(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.Error is { } error + ? Results.Redirect(WithMessage(ListPage + "?server=" + Uri.EscapeDataString(form["server"].ToString()), "fejl", error)) + : Results.Redirect(DetailPage(outcome.Id!.Value) + "?tilfoejet=1"); + } + + private static async Task UpdateAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var outcome = await InstanceSetup.UpdateAsync(repository, id, Input(form), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + if (outcome.NotFound) + { + return Results.NotFound(); + } + + return Results.Redirect(outcome.Error is { } error ? WithMessage(DetailPage(id), "fejl", error) : DetailPage(id) + "?gemt=1"); + } + + private static async Task PauseAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var paused = form["paused"] == "true"; + return await repository.SetInstancePausedAsync(id, paused, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) + == ConfigurationChangeOutcome.Changed + ? Results.Redirect(DetailPage(id) + (paused ? "?pause=1" : "?genoptaget=1")) + : Results.NotFound(); + } + + private static async Task RemoveAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + return await repository.RemoveConfiguredInstanceAsync(id, http.User.Identity?.Name, DateTimeOffset.UtcNow, http.RequestAborted).ConfigureAwait(false) switch + { + ConfigurationChangeOutcome.Changed => Results.Redirect(ListPage + "?fjernet=1"), + ConfigurationChangeOutcome.FromConfigFile => Results.Redirect(WithMessage(DetailPage(id), "fejl", + "Instansen kommer fra agentens konfigurationsfil og fjernes dér (SQLInsight:Instances). Den kan sættes på pause her.")), + _ => Results.NotFound(), + }; + } + + private static async Task RequestAsync(int id, string kind, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var outcome = await InstanceSetup.RequestAsync(repository, id, kind, http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return outcome.NotFound ? Results.NotFound() : Results.Redirect(DetailPage(id) + (kind == AgentRequestKind.ConnectionTest ? "#forbindelse" : "#databaser")); + } + + private static async Task DatabasesAsync(int id, HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, DetailPage(id)).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var monitored = new HashSet(form["monitor"].OfType(), StringComparer.OrdinalIgnoreCase); + var ax = new HashSet(form["ax"].OfType(), StringComparer.OrdinalIgnoreCase); + var known = await repository.GetConfiguredDatabasesAsync(id, http.RequestAborted).ConfigureAwait(false); + var choices = known.Where(d => !d.Gone).Select(d => new DatabaseChoice(d.DatabaseName, monitored.Contains(d.DatabaseName), ax.Contains(d.DatabaseName))).ToList(); + var (_, found) = await InstanceSetup.ChooseDatabasesAsync(repository, id, choices, form["monitorNew"] == "true", http.User.Identity?.Name, http.RequestAborted) + .ConfigureAwait(false); + return found ? Results.Redirect(DetailPage(id) + "?databaser=1#databaser") : Results.NotFound(); + } + + private static async Task FindAsync(HttpContext http, InsightRepository repository, IAntiforgery antiforgery, IOptions options) + { + if (await RefuseAsync(http, antiforgery, options.Value, ListPage).ConfigureAwait(false) is { } refused) + { + return refused; + } + + var form = await http.Request.ReadFormAsync(http.RequestAborted).ConfigureAwait(false); + var outcome = await InstanceSetup.FindInstancesAsync(repository, form["host"].ToString(), http.User.Identity?.Name, http.RequestAborted).ConfigureAwait(false); + return Results.Redirect(outcome.Error is { } error + ? WithMessage(ListPage, "fejl", error) + : ListPage + "?soegning=" + outcome.RequestId!.Value.ToString(CultureInfo.InvariantCulture) + "#find"); + } +} diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs index e2ceb95..420da15 100644 --- a/src/SQLInsight.Web/Program.cs +++ b/src/SQLInsight.Web/Program.cs @@ -116,8 +116,10 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit IntegrationEndpoints.Map(app); ApiV1Endpoints.Map(app); SupportApiEndpoints.Map(app); +ConfigurationApiEndpoints.Map(app); app.MapOpenApi("/api/v1/openapi.json").RequireRead(); Support.Map(app); +InstanceSetupEndpoints.Map(app); // Management and compliance report as PDF, for the last N days. app.MapGet("/rapporter/driftsrapport.pdf", async (int? dage, InsightRepository repository, Microsoft.Extensions.Options.IOptions options, CancellationToken ct) => diff --git a/src/SQLInsight.Web/WebOptions.cs b/src/SQLInsight.Web/WebOptions.cs index 09ed4bd..fb84a20 100644 --- a/src/SQLInsight.Web/WebOptions.cs +++ b/src/SQLInsight.Web/WebOptions.cs @@ -10,6 +10,12 @@ public sealed class WebOptions /// Windows groups allowed to use the UI (e.g. DOMAIN\SQL-Admins). Empty: any authenticated user. public IList AllowedGroups { get; } = []; + /// + /// Windows groups that may change which servers and databases are monitored (Konfiguration og sikkerhed → Servere + /// og databaser), e.g. DOMAIN\SQL-Admins. Empty: nobody can change it in the UI; everyone with access can see it. + /// + public IList AdminGroups { get; } = []; + /// Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone. public string? TimeZone { get; set; } diff --git a/src/SQLInsight.Web/wwwroot/app.css b/src/SQLInsight.Web/wwwroot/app.css index e79b041..49ee481 100644 --- a/src/SQLInsight.Web/wwwroot/app.css +++ b/src/SQLInsight.Web/wwwroot/app.css @@ -226,3 +226,14 @@ details.not-applicable > table { margin-top: .75rem; } .nav-toggle:focus-visible + .nav-toggle-label { outline: 2px solid var(--accent); } .nav-toggle:not(:checked) ~ .nav, .nav-toggle:not(:checked) ~ .support-card { display: none; } } + +/* Servere og databaser (/opsaetning/instanser): forms, actions and the database choice. */ +.support-form select { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: .5rem .65rem; } +label.check { display: flex; align-items: center; gap: .45rem; font-size: .9rem; color: var(--text); } +label.check input { width: auto; margin: 0; } +.report-links form { margin: 0; } +button.button { border: 0; cursor: pointer; font: inherit; background: var(--accent); color: #fff; padding: .5rem 1rem; border-radius: 8px; font-weight: 600; } +button.button:hover { filter: brightness(1.08); } +button.button.secondary { background: var(--surface-2); color: var(--text); border: 1px solid var(--border); } +button.button.danger { background: var(--critical); } +tr.muted td { color: var(--text-2); } diff --git a/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs b/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs new file mode 100644 index 0000000..8040563 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using SQLInsight.Core.Configuration; +using SQLInsight.Repository; +using SQLInsight.Web.Api; + +namespace SQLInsight.IntegrationTests; + +/// The configuration through API v1 with Windows authentication on and bearer tokens with and without config:write. +[Collection(SqlServerTestGroup.Name)] +[Trait("Category", "Integration")] +public sealed class InstanceSetupApiTests(SqlServerFixture sql) : IAsyncLifetime +{ + private const string ReadToken = "sqli_test-config-read"; + private const string WriteToken = "sqli_test-config-write"; + private readonly string _database = "SQLInsightCfgApi_" + Guid.NewGuid().ToString("N")[..8]; + private WebApplicationFactory _factory = null!; + private InsightRepository _repository = null!; + private int _port; + + public async Task InitializeAsync() + { + using (var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0)) + { + probe.Start(); + _port = ((IPEndPoint)probe.LocalEndpoint).Port; + } + + await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];"); + _repository = new InsightRepository(sql.DatabaseConnectionString(_database)); + await _repository.MigrateAsync(); + using var rsa = RSA.Create(3072); + await _repository.PublishAgentKeyAsync(new AgentKeyRow + { + KeyId = "agent-api", PublicKey = rsa.ExportSubjectPublicKeyInfo(), MachineName = "AGENT01", ServiceAccount = @"FIRMA\svc-sqlinsight$", Domain = "FIRMA", + CreatedAt = DateTime.UtcNow, LastSeenAt = DateTime.UtcNow, + }); + _factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("SQLInsight:Repository:ConnectionString", sql.DatabaseConnectionString(_database)); + b.UseSetting("SQLInsight:Web:Authentication", "Windows"); + b.UseSetting("SQLInsight:Web:AdminGroups:0", @"FIRMA\SQL-Admins"); + b.UseSetting("SQLInsight:Web:ApiTokens:grafana", "sha256:" + ApiTokenHandler.Hash(ReadToken)); + b.UseSetting("SQLInsight:Web:ApiTokens:automation:hash", "sha256:" + ApiTokenHandler.Hash(WriteToken)); + b.UseSetting("SQLInsight:Web:ApiTokens:automation:scopes:0", "read"); + b.UseSetting("SQLInsight:Web:ApiTokens:automation:scopes:1", "config:write"); + b.UseSetting("urls", $"http://127.0.0.1:{_port}"); + }); + _factory.UseKestrel(); + _factory.StartServer(); + } + + public async Task DisposeAsync() + { + await _factory.DisposeAsync(); + await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];"); + } + + private HttpClient Client(string? token) + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri($"http://127.0.0.1:{_port}") }); + if (token is not null) + { + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return client; + } + + private static StringContent Body(object value) => new(JsonSerializer.Serialize(value), Encoding.UTF8, "application/json"); + + private static async Task JsonAsync(HttpResponseMessage response) => JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + + [Fact] + public async Task Everything_on_the_page_is_in_the_api_and_changing_needs_config_write() + { + var write = Client(WriteToken); + var read = Client(ReadToken); + var instance = new { server = @"sql01\PROD", authentication = "SqlLogin", userName = "mon", password = "Api-Hemmelig-1!", trustServerCertificate = true }; + + Assert.Equal(HttpStatusCode.Unauthorized, (await Client(null).GetAsync("/api/v1/monitored-instances")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await read.PostAsync("/api/v1/monitored-instances", Body(instance))).StatusCode); + var created = await write.PostAsync("/api/v1/monitored-instances", Body(instance)); + Assert.Equal(HttpStatusCode.Created, created.StatusCode); + var body = await JsonAsync(created); + var id = body.GetProperty("instance").GetProperty("id").GetInt32(); + var requestId = body.GetProperty("connectionTestRequestId").GetInt64(); + Assert.True(body.GetProperty("instance").GetProperty("hasPassword").GetBoolean()); + Assert.DoesNotContain("Api-Hemmelig", body.ToString(), StringComparison.Ordinal); + + var list = await JsonAsync(await read.GetAsync("/api/v1/monitored-instances")); + Assert.Equal(@"sql01\PROD", Assert.Single(list.EnumerateArray()).GetProperty("name").GetString()); + Assert.Equal("Pending", (await JsonAsync(await read.GetAsync($"/api/v1/agent-requests/{requestId}"))).GetProperty("status").GetString()); + + Assert.Equal(HttpStatusCode.Forbidden, (await read.PatchAsync($"/api/v1/monitored-instances/{id}", Body(new { paused = true }))).StatusCode); + Assert.True((await JsonAsync(await write.PatchAsync($"/api/v1/monitored-instances/{id}", Body(new { paused = true })))).GetProperty("paused").GetBoolean()); + Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { }))).StatusCode); + Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync($"/api/v1/monitored-instances/{id}/discovery", Body(new { }))).StatusCode); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PutAsync($"/api/v1/monitored-instances/{id}/databases", + Body(new { databases = new[] { new { name = "Sales", monitored = true } } }))).StatusCode); + await _repository.SaveDiscoveredDatabasesAsync(id, [new DiscoveredDatabase { Name = "Sales", HasAccess = true }, new DiscoveredDatabase { Name = "AX30", HasAccess = true, IsDynamicsAx = true }], + DateTimeOffset.UtcNow); + var chosen = await JsonAsync(await write.PutAsync($"/api/v1/monitored-instances/{id}/databases", + Body(new { monitorNewDatabases = true, databases = new object[] { new { name = "Sales", monitored = true }, new { name = "AX30", monitored = true, sendDynamicsAxToHub = true } } }))); + Assert.Contains(chosen.EnumerateArray(), d => d.GetProperty("name").GetString() == "AX30" && d.GetProperty("sendDynamicsAxToHub").GetBoolean()); + Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases); + Assert.Equal(2, (await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/databases"))).GetArrayLength()); + + var grant = await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/grant-script")); + Assert.Equal("mon", grant.GetProperty("login").GetString()); + Assert.Contains("SET @LoginName = N'mon';", grant.GetProperty("script").GetString(), StringComparison.Ordinal); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, (await write.PostAsync("/api/v1/instance-discovery", Body(new { host = @"sql01\PROD" }))).StatusCode); + Assert.Equal(HttpStatusCode.Accepted, (await write.PostAsync("/api/v1/instance-discovery", Body(new { host = "sql01.firma.local" }))).StatusCode); + + Assert.Equal(HttpStatusCode.Forbidden, (await read.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode); + Assert.Equal(HttpStatusCode.NoContent, (await write.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode); + Assert.Empty((await JsonAsync(await read.GetAsync("/api/v1/monitored-instances"))).EnumerateArray()); + } + + [Fact] + public async Task A_configuration_file_instance_cannot_be_changed_or_removed_through_the_api() + { + var id = (await _repository.SyncConfigFileInstancesAsync([new ConfigFileInstance("prod-sql01", "sql01", "Windows", null, "Mandatory", false)], DateTimeOffset.UtcNow))["prod-sql01"]; + var write = Client(WriteToken); + + Assert.Equal(HttpStatusCode.Conflict, (await write.DeleteAsync($"/api/v1/monitored-instances/{id}")).StatusCode); + Assert.Equal(HttpStatusCode.Conflict, (await write.PutAsync($"/api/v1/monitored-instances/{id}/connection", Body(new { server = "evil", authentication = "Windows" }))).StatusCode); + Assert.Equal("configFile", (await JsonAsync(await write.GetAsync($"/api/v1/monitored-instances/{id}"))).GetProperty("source").GetString()); + } +} diff --git a/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs b/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs new file mode 100644 index 0000000..1cf5271 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/InstanceSetupWebTests.cs @@ -0,0 +1,252 @@ +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; + +/// Servere og databaser in SQLInsight Web: adding, testing, choosing databases, the GRANT script and passwords. +[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 _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().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 }); + + /// Posts a form the way the browser does: with the antiforgery token from . + private static async Task PostAsync(HttpClient client, string page, string action, Dictionary fields, params (string Name, string Value)[] repeated) + { + var html = await client.GetStringAsync(page); + var values = new List> { new("__RequestVerificationToken", WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value)) }; + values.AddRange(fields); + values.AddRange(repeated.Select(r => new KeyValuePair(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 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 + { + ["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 { ["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)); + } +} +