diff --git a/docs/servere-og-databaser.md b/docs/servere-og-databaser.md index 27fa449..018eb94 100644 --- a/docs/servere-og-databaser.md +++ b/docs/servere-og-databaser.md @@ -73,6 +73,8 @@ Webbrugerfladens login har stadig kun `db_datareader` og ellers præcis det, den Alt på siden findes i API v1 (se [integration.md](integration.md#json-api)). Læsning kræver scope `read`; ændringer kræver scope `config:write` for tokens eller medlemskab af `AdminGroups` for Windows-brugere (er den tom: den lokale gruppe Administratorer på webserveren). Opret et token med `SQLInsight.Web.exe new-api-token automation --scope read,config:write`. +Som i resten af API v1 vises logins (SQL-login'et, det login agenten ankom som, og logins i fejltekster) og navnene på dem, der har ændret noget, som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`; GRANT-scriptet er så det leverede script med pladsholderen i `@LoginName`. Adgangskoder returneres aldrig. Beder man om en forbindelsestest eller søgning, mens den samme stadig venter på agenten, får man den ventende anmodning igen. + | Endpoint | | |---|---| | `GET /api/v1/monitored-instances` | Overvågede servere | diff --git a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs index 159e7c0..0d1fea4 100644 --- a/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs +++ b/src/SQLInsight.Web/Api/ConfigurationApiEndpoints.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using Microsoft.Extensions.Options; using SQLInsight.Core.Configuration; using SQLInsight.Repository; @@ -31,23 +32,27 @@ 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 (the -/// web server's local Administrators while that is empty) 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. +/// Reading needs the scope read; changing needs config:write for tokens, or for Windows users membership of +/// SQLInsight:Web:AdminGroups (the web server's local Administrators while that is empty). 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. Logins (the SQL login, the login the agent arrived as) and the names of +/// the people who made changes are customer data and returned as "[udeladt]" unless +/// SQLInsight:Web:ApiIncludesSensitiveEvidence is on, as in the rest of API v1. /// -public static class ConfigurationApiEndpoints +public static partial 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 for Windows users SQLInsight:Web:AdminGroups (empty: the web server's local Administrators)."; + const string redacted = " Logins and user names are \"[udeladt]\" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on."; - api.MapGet("/monitored-instances", ListAsync).WithSummary("Overvågede instanser / Monitored instances (configuration)"); + api.MapGet("/monitored-instances", ListAsync).WithSummary("Overvågede instanser / Monitored instances (configuration)").WithDescription(redacted.Trim()); 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.MapGet("/monitored-instances/{id:int}", GetAsync).WithSummary("Én overvåget instans / One monitored instance").WithDescription(redacted.Trim()); 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) @@ -58,30 +63,50 @@ public static class ConfigurationApiEndpoints .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); + .WithSummary("Test forbindelsen fra agenten / Test the connection from the agent") + .WithDescription("202 Accepted with requestId. A test already waiting for the agent is returned instead of a new one." + write); api.MapGet("/monitored-instances/{id:int}/connection-test", LatestTestAsync) - .WithSummary("Seneste forbindelsestest med manglende rettigheder / Latest connection test and missing permissions"); + .WithSummary("Seneste forbindelsestest med manglende rettigheder / Latest connection test and missing permissions").WithDescription(redacted.Trim()); 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"); + .WithSummary("Søg efter databaser / Discover databases") + .WithDescription("202 Accepted with requestId; the result is in GET …/databases. A discovery already waiting is returned instead of a new one." + write); + api.MapGet("/monitored-instances/{id:int}/databases", DatabasesAsync).WithSummary("Fundne databaser og valg / Discovered databases and choices").WithDescription(redacted.Trim()); 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"); + .WithSummary("Least-privilege GRANT-script til agentens login / Least-privilege GRANT script for the agent's login") + .WithDescription("With the login filled in only when SQLInsight:Web:ApiIncludesSensitiveEvidence is on; otherwise the shipped script with its placeholder."); 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"); + api.MapGet("/agent-requests/{id:long}", RequestAsync) + .WithSummary("Status og resultat af en anmodning til agenten / Status and result of an agent request").WithDescription(redacted.Trim()); } + /// Logins and people's names, "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on. + internal sealed class Sensitive(bool include) + { + public static Sensitive For(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence); + + public bool Include => include; + + public string? Value(string? value) => include || value is null ? value : ApiV1Endpoints.Redacted; + + /// An error text from SQL Server or the agent, with the login in it (Login failed for user '…') left out. + public string? Text(string? value) => include || value is null ? value : LoginInText().Replace(value, m => m.Groups[1].Value + "'" + ApiV1Endpoints.Redacted + "'"); + } + + [GeneratedRegex(@"(\b(?:user|login|bruger|login'et)\s+)'[^'\r\n]*'", RegexOptions.IgnoreCase)] + private static partial Regex LoginInText(); + 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 (er den tom: den lokale gruppe Administratorer på webserveren)."); private static IResult Invalid(string detail) => Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity, title: "Ugyldig", detail: detail); - private static object Json(ConfiguredInstanceRow i) => new + private static object Json(ConfiguredInstanceRow i, Sensitive s) => new { Id = i.ConfiguredInstanceId, i.Name, @@ -89,7 +114,7 @@ public static class ConfigurationApiEndpoints i.Port, i.DataSource, i.Authentication, - i.UserName, + UserName = s.Value(i.UserName), i.HasPassword, i.Encryption, i.TrustServerCertificate, @@ -97,15 +122,15 @@ public static class ConfigurationApiEndpoints i.Paused, i.MonitorNewDatabases, i.CreatedAt, - i.CreatedBy, + CreatedBy = s.Value(i.CreatedBy), i.UpdatedAt, - i.UpdatedBy, + UpdatedBy = s.Value(i.UpdatedBy), i.LastDiscoveryAt, - i.LastDiscoveryError, - i.CredentialError, + LastDiscoveryError = s.Text(i.LastDiscoveryError), + CredentialError = s.Text(i.CredentialError), }; - private static object Json(ConfiguredDatabaseRow d) => new + private static object Json(ConfiguredDatabaseRow d, Sensitive s) => new { Name = d.DatabaseName, d.Monitored, @@ -124,31 +149,51 @@ public static class ConfigurationApiEndpoints d.FirstSeenAt, d.LastSeenAt, d.SelectionChangedAt, - d.SelectionChangedBy, + SelectionChangedBy = s.Value(d.SelectionChangedBy), }; - private static object Json(AgentRequestRow r) => new + private static object Json(AgentRequestRow r, Sensitive s) => new { Id = r.RequestId, r.Kind, InstanceId = r.ConfiguredInstanceId, r.Status, - r.RequestedBy, + RequestedBy = s.Value(r.RequestedBy), r.RequestedAt, r.StartedAt, r.CompletedAt, - r.Error, - Result = r.Result is { Length: > 0 } result ? JsonDocument.Parse(result).RootElement.Clone() : (JsonElement?)null, + Error = s.Text(r.Error), + Result = ResultJson(r, s), }; + /// The agent's result; a connection test's login and error text are redacted like the rest. + private static JsonElement? ResultJson(AgentRequestRow r, Sensitive s) + { + if (r.Result is not { Length: > 0 } result) + { + return null; + } + + if (!s.Include && r.Kind == AgentRequestKind.ConnectionTest && AgentRequestJson.Deserialize(result) is { } test) + { + result = AgentRequestJson.Serialize(test with { Login = s.Value(test.Login), Error = s.Text(test.Error) }); + } + + using var document = JsonDocument.Parse(result); + return document.RootElement.Clone(); + } + 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 ListAsync(InsightRepository repository, IOptions options, CancellationToken ct) + { + var s = Sensitive.For(options); + return Results.Ok((await repository.GetConfiguredInstancesAsync(ct).ConfigureAwait(false)).Select(i => Json(i, s))); + } - 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 GetAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) => + await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is { } row ? Results.Ok(Json(row, Sensitive.For(options))) : Results.NotFound(); private static async Task AddAsync(MonitoredInstanceBody body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) { @@ -164,7 +209,7 @@ public static class ConfigurationApiEndpoints } 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 }); + return Results.Created($"/api/v1/monitored-instances/{row.ConfiguredInstanceId}", new { Instance = Json(row, Sensitive.For(options)), ConnectionTestRequestId = outcome.RequestId }); } private static async Task PatchAsync(int id, MonitoredInstancePatch body, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) @@ -190,7 +235,7 @@ public static class ConfigurationApiEndpoints await repository.SetMonitorNewDatabasesAsync(id, monitorNew, by, DateTimeOffset.UtcNow, ct).ConfigureAwait(false); } - return Results.Ok(Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!)); + return Results.Ok(Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!, Sensitive.For(options))); } private static async Task UpdateConnectionAsync(int id, MonitoredInstanceBody body, HttpContext http, InsightRepository repository, IOptions options, @@ -214,7 +259,11 @@ public static class ConfigurationApiEndpoints : Invalid(error); } - return Results.Ok(new { Instance = Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!), ConnectionTestRequestId = outcome.RequestId }); + return Results.Ok(new + { + Instance = Json((await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false))!, Sensitive.For(options)), + ConnectionTestRequestId = outcome.RequestId, + }); } private static async Task RemoveAsync(int id, HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct) @@ -247,10 +296,10 @@ public static class ConfigurationApiEndpoints } var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!; - return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request)); + return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request, Sensitive.For(options))); } - private static async Task LatestTestAsync(int id, InsightRepository repository, CancellationToken ct) + private static async Task LatestTestAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) { if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null) { @@ -264,17 +313,18 @@ public static class ConfigurationApiEndpoints } var result = AgentRequestJson.Deserialize(test.Result); - return Results.Ok(new { Request = Json(test), MissingPermissions = result?.MissingRequired.Select(p => p.Name).ToList() ?? [] }); + return Results.Ok(new { Request = Json(test, Sensitive.For(options)), MissingPermissions = result?.MissingRequired.Select(p => p.Name).ToList() ?? [] }); } - private static async Task DatabasesAsync(int id, InsightRepository repository, CancellationToken ct) + private static async Task DatabasesAsync(int id, InsightRepository repository, IOptions options, 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)); + var s = Sensitive.For(options); + return Results.Ok((await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).Select(d => Json(d, s))); } private static async Task ChooseAsync(int id, DatabaseChoicesBody body, HttpContext http, InsightRepository repository, IOptions options, @@ -285,6 +335,11 @@ public static class ConfigurationApiEndpoints return Forbidden(); } + if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is null) + { + return Results.NotFound(); + } + 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))) @@ -292,20 +347,23 @@ public static class ConfigurationApiEndpoints return Invalid("Hver database skal have et navn."); } - var unknown = requested.Where(d => !known.ContainsKey(d.Name!)).Select(d => d.Name!).ToList(); + var unknown = requested.Where(d => !known.ContainsKey(d.Name!)).Select(d => d.Name!).Distinct(StringComparer.OrdinalIgnoreCase).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."); + return Invalid("Agenten har ikke fundet disse databaser: " + string.Join(", ", unknown.Take(20)) + (unknown.Count > 20 ? $" og {unknown.Count - 20} flere" : string.Empty) + + ". 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)) + .DistinctBy(c => c.DatabaseName, StringComparer.OrdinalIgnoreCase) .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(); + var s = Sensitive.For(options); + return found ? Results.Ok((await repository.GetConfiguredDatabasesAsync(id, ct).ConfigureAwait(false)).Select(d => Json(d, s))) : Results.NotFound(); } - private static async Task GrantScriptAsync(int id, InsightRepository repository, CancellationToken ct) + private static async Task GrantScriptAsync(int id, InsightRepository repository, IOptions options, CancellationToken ct) { if (await repository.GetConfiguredInstanceAsync(id, ct).ConfigureAwait(false) is not { } row) { @@ -313,6 +371,17 @@ public static class ConfigurationApiEndpoints } var (login, script, note) = await InstanceSetup.GrantScriptAsync(repository, row, ct).ConfigureAwait(false); + if (!Sensitive.For(options).Include && login is not null) + { + // The script names the login; without sensitive data it is the shipped script with its placeholder. + return Results.Ok(new + { + Login = ApiV1Endpoints.Redacted, + Script = GrantScript.Template(), + Note = "Login'et er udeladt (SQLInsight:Web:ApiIncludesSensitiveEvidence er slået fra). Ret @LoginName i scriptet, eller se det udfyldte script under Servere og databaser.", + }); + } + return Results.Ok(new { Login = login, Script = script, Note = note }); } @@ -330,9 +399,9 @@ public static class ConfigurationApiEndpoints } var request = (await repository.GetAgentRequestAsync(outcome.RequestId!.Value, ct).ConfigureAwait(false))!; - return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request)); + return Results.Accepted($"/api/v1/agent-requests/{request.RequestId}", Json(request, Sensitive.For(options))); } - 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(); + private static async Task RequestAsync(long id, InsightRepository repository, IOptions options, CancellationToken ct) => + await repository.GetAgentRequestAsync(id, ct).ConfigureAwait(false) is { } request ? Results.Ok(Json(request, Sensitive.For(options))) : Results.NotFound(); } diff --git a/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs b/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs index 8040563..5babccc 100644 --- a/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs +++ b/tests/SQLInsight.IntegrationTests/InstanceSetupApiTests.cs @@ -113,9 +113,18 @@ public sealed class InstanceSetupApiTests(SqlServerFixture sql) : IAsyncLifetime Assert.True((await _repository.GetConfiguredInstanceAsync(id))!.MonitorNewDatabases); Assert.Equal(2, (await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}/databases"))).GetArrayLength()); + // Logins and people's names are redacted as in the rest of API v1 (ApiIncludesSensitiveEvidence is off). 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(ApiV1Endpoints.Redacted, grant.GetProperty("login").GetString()); + Assert.DoesNotContain("N'mon'", grant.GetProperty("script").GetString(), StringComparison.Ordinal); + Assert.Contains("SET @LoginName = N'", grant.GetProperty("script").GetString(), StringComparison.Ordinal); + var shown = await JsonAsync(await read.GetAsync($"/api/v1/monitored-instances/{id}")); + Assert.Equal(ApiV1Endpoints.Redacted, shown.GetProperty("userName").GetString()); + + // Asking again while the agent has not taken the test yet returns the waiting one; the queue does not grow. + var again = await JsonAsync(await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { }))); + var once = await JsonAsync(await write.PostAsync($"/api/v1/monitored-instances/{id}/connection-test", Body(new { }))); + Assert.Equal(again.GetProperty("id").GetInt64(), once.GetProperty("id").GetInt64()); 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);