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);