diff --git a/Directory.Packages.props b/Directory.Packages.props
index 004a401..db6630a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -28,6 +28,7 @@
+
diff --git a/docs/integration.md b/docs/integration.md
index 5fdd259..e439565 100644
--- a/docs/integration.md
+++ b/docs/integration.md
@@ -60,17 +60,62 @@ Eksempel på alarm i Prometheus:
## JSON-API
+Alt, hvad webbrugerfladen viser, kan hentes som JSON under `/api/v1/` med de samme tidsrum og filtre, og rapporterne også som PDF. API'et er beskrevet i OpenAPI på `GET /api/v1/openapi.json` (samme login som API'et), som fx Postman, Power BI eller en kodegenerator kan læse. Tider er ISO 8601 (UTC eller med tidszone), fx `2026-10-01T08:00:00Z`.
+
| Kald | Svar |
|---|---|
| `GET /api/v1/instances` | Instanser med version, seneste kontakt, `up` og antal fund pr. alvor |
-| `GET /api/v1/findings?instance=prod-sql01&minSeverity=High` | Aktive fund med forklaring, anbefaling og målinger |
+| `GET /api/v1/findings` | Fund. Filtre: `status=active\|resolved\|all` (standard active), `from`/`to` (opstået for aktive, løst for løste, begge for all), `instance`, `database` (emnet nævner databasen), `ruleId`, `minSeverity=Info\|Low\|Medium\|High\|Critical`, `category` (fx `SEC` i SQLI-SEC-001), `sort=severity\|firstSeen\|lastSeen\|resolved`, `limit` (1–1000, standard 100). Er der flere, står værdien til `?continuation=` i svar-headeren `X-Continuation` |
+| `GET /api/v1/findings/{id}` | Ét fund med forklaring, anbefaling, målinger, fix-script, referencer og historik (først/sidst set, løst) |
+| `GET /api/v1/rules` | Alle regler med id, titel, kategori og kilde (`indbygget`, `community`, `lokal`) |
+| `GET /api/v1/instances/{navn}/rule-states` | Hver regels udfald på instansen: `passed`, `findings`, `notMeasurable`, `notApplicable`, `error`, med årsag |
+| `GET /api/v1/reports` | Rapporttyperne og deres parametre |
+| `GET /api/v1/reports/driftsrapport?days=30&format=json\|pdf` | Driftsrapporten: nøgletal, sammenfatning, kontroller pr. instans, instanser, aktive fund, konfigurationsændringer |
+| `GET /api/v1/reports/analyse?instance=…&from=…&to=…&sort=cpu&format=json\|pdf` | Analyse af et tidsrum (som siden og PDF'en) |
+| `GET /api/v1/reports/rettigheder?days=90&instance=…&format=json\|pdf` | Rettighedsrevisionen for én eller alle instanser |
+| `GET /api/v1/instances/{navn}/analysis?from=&to=&sort=` | Analyse af tidsrum: målinger med vurdering, ventetider, tungeste sætninger med aktivitet, diske, blocking, deadlocks, langvarige operationer, fund |
+| `GET /api/v1/instances/{navn}/top-sql?from=&to=&sort=cpu\|varighed\|laesninger\|koersler&limit=` | Tungeste sætninger i et tidsrum (standard seneste døgn) |
+| `GET /api/v1/instances/{navn}/disks?from=&to=` | Diskhastighed pr. drev med vurdering og tidsserier |
+| `GET /api/v1/instances/{navn}/configuration?days=90` | Opsætning, tjenester, tempdb, sp_configure, trace flags og ændringslog |
+| `GET /api/v1/instances/{navn}/privileges?days=90` | Rettigheder med udfoldede Windows-grupper, sa, adgangskodepolitik, default trace og ændringer |
+| `GET /api/v1/privileges?days=90` | Det samme for alle instanser |
+| `GET /api/v1/instances/{navn}/wait-types/{ventetype}?from=&to=` | Forklaring, udvikling og de sætninger, der ventede |
| `GET /metrics` | Prometheus-format |
-```powershell
-Invoke-RestMethod https://sqlinsight.domæne.local/api/v1/findings?minSeverity=High -Headers @{ Authorization = "Bearer $token" }
+**Følsomme data:** forespørgselstekster, logins, personnavne, værts- og programnavne og tjenestekonti vises som `[udeladt]`, medmindre `SQLInsight:Web:ApiIncludesSensitiveEvidence` er `true`. Det gælder både fund, rapporter i JSON og sidernes data. PDF-rapporterne indeholder de samme data som i webbrugerfladen.
+
+**Tokens og scopes:** et token i den gamle form (`"navn": "sha256:…"`) må kun læse (`read`). Et token med flere rettigheder skrives som
+
+```json
+"ApiTokens": { "itsm": { "hash": "sha256:…", "scopes": [ "read", "support:write" ] } }
```
-Følsomme målinger (forespørgselstekst, logins, værtsnavne) vises som `[udeladt]` i API'et. Sæt `SQLInsight:Web:ApiIncludesSensitiveEvidence` til `true` for at få dem med.
+og oprettes med `SQLInsight.Web.exe new-api-token itsm --scope read,support:write`. Windows-brugere med adgang til webbrugerfladen må det samme som i webbrugerfladen.
+
+PowerShell:
+
+```powershell
+$h = @{ Authorization = "Bearer $token" }
+Invoke-RestMethod "https://sqlinsight.domæne.local/api/v1/findings?minSeverity=High" -Headers $h
+Invoke-WebRequest "https://sqlinsight.domæne.local/api/v1/reports/driftsrapport?days=30&format=pdf" -Headers $h -OutFile driftsrapport.pdf
+
+# Alle løste fund det seneste kvartal, side for side
+$url = "https://sqlinsight.domæne.local/api/v1/findings?status=resolved&from=$((Get-Date).AddDays(-90).ToString('o'))&limit=500"
+$all = @(); $next = $null
+do {
+ $r = Invoke-WebRequest ($url + $(if ($next) { "&continuation=$next" })) -Headers $h
+ $all += $r.Content | ConvertFrom-Json
+ $next = $r.Headers['X-Continuation']
+} while ($next)
+```
+
+curl:
+
+```bash
+curl -H "Authorization: Bearer $TOKEN" "https://sqlinsight.domæne.local/api/v1/instances/AX01%5CAX30/top-sql?sort=cpu&limit=10"
+```
+
+**Power BI og Excel:** Hent data → Fra internettet → Avanceret, angiv URL'en (fx `/api/v1/findings?status=all&limit=1000`) og en HTTP-header `Authorization` med værdien `Bearer `. Brug et token med kun `read`.
## SCOM og Windows' hændelseslog
diff --git a/src/SQLInsight.Repository/InsightRepository.FindingSearch.cs b/src/SQLInsight.Repository/InsightRepository.FindingSearch.cs
new file mode 100644
index 0000000..4b21794
--- /dev/null
+++ b/src/SQLInsight.Repository/InsightRepository.FindingSearch.cs
@@ -0,0 +1,97 @@
+using Dapper;
+using SQLInsight.Core.Findings;
+
+namespace SQLInsight.Repository;
+
+public enum FindingStatus
+{
+ Active,
+ Resolved,
+ All,
+}
+
+public enum FindingSort
+{
+ /// Most severe first, then oldest.
+ Severity,
+ FirstSeen,
+ LastSeen,
+ Resolved,
+}
+
+///
+/// A search for findings. / select by when they arose (active), were resolved
+/// (resolved), or either (all). is the middle part of the rule id (SEC in SQLI-SEC-001).
+/// matches findings whose subject names the database.
+///
+public sealed record FindingQuery
+{
+ public FindingStatus Status { get; init; } = FindingStatus.Active;
+ public DateTimeOffset? From { get; init; }
+ public DateTimeOffset? To { get; init; }
+ public string? Instance { get; init; }
+ public string? Database { get; init; }
+ public string? RuleId { get; init; }
+ public Severity? MinSeverity { get; init; }
+ public string? Category { get; init; }
+ public FindingSort Sort { get; init; } = FindingSort.Severity;
+ public int Offset { get; init; }
+ public int Limit { get; init; } = 100;
+}
+
+public sealed partial class InsightRepository
+{
+ public const int MaxFindingPage = 1000;
+
+ /// One page of findings matching , and whether there are more.
+ public async Task<(IReadOnlyList Findings, bool More)> SearchFindingsAsync(FindingQuery query, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ var limit = Math.Clamp(query.Limit, 1, MaxFindingPage);
+ var status = query.Status switch
+ {
+ FindingStatus.Active => "f.ResolvedAt IS NULL",
+ FindingStatus.Resolved => "f.ResolvedAt IS NOT NULL",
+ _ => "1 = 1",
+ };
+ var period = query.Status switch
+ {
+ FindingStatus.Active => "(@from IS NULL OR f.FirstSeenAt >= @from) AND (@to IS NULL OR f.FirstSeenAt <= @to)",
+ FindingStatus.Resolved => "(@from IS NULL OR f.ResolvedAt >= @from) AND (@to IS NULL OR f.ResolvedAt <= @to)",
+ _ => "((@from IS NULL OR f.FirstSeenAt >= @from) AND (@to IS NULL OR f.FirstSeenAt <= @to) OR f.ResolvedAt >= @from AND (@to IS NULL OR f.ResolvedAt <= @to))",
+ };
+ var order = query.Sort switch
+ {
+ FindingSort.FirstSeen => "f.FirstSeenAt DESC, f.FindingId DESC",
+ FindingSort.LastSeen => "f.LastSeenAt DESC, f.FindingId DESC",
+ FindingSort.Resolved => "f.ResolvedAt DESC, f.FindingId DESC",
+ _ => "f.Severity DESC, f.FirstSeenAt, f.FindingId",
+ };
+
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = (await connection.QueryAsync($"""
+ SELECT {FindingColumns}
+ FROM insight.Findings f JOIN insight.Instances i ON i.InstanceId = f.InstanceId
+ WHERE {status} AND {period}
+ AND (@instance IS NULL OR i.Name = @instance)
+ AND (@ruleId IS NULL OR f.RuleId = @ruleId)
+ AND (@minSeverity IS NULL OR f.Severity >= @minSeverity)
+ AND (@category IS NULL OR f.RuleId LIKE N'%-' + @category + N'-%')
+ AND (@database IS NULL OR f.Subject = N'database: ' + @database OR f.Subject LIKE N'%' + @database + N'%')
+ ORDER BY {order}
+ OFFSET @offset ROWS FETCH NEXT @take ROWS ONLY;
+ """, new
+ {
+ from = query.From?.UtcDateTime,
+ to = query.To?.UtcDateTime,
+ instance = query.Instance,
+ ruleId = query.RuleId,
+ minSeverity = query.MinSeverity is { } s ? (byte?)s : null,
+ category = query.Category,
+ database = query.Database,
+ offset = Math.Max(0, query.Offset),
+ take = limit + 1,
+ }).ConfigureAwait(false)).ToList();
+ return (rows.Take(limit).Select(Map).ToList(), rows.Count > limit);
+ }
+}
diff --git a/src/SQLInsight.Repository/InsightRepository.Reports.cs b/src/SQLInsight.Repository/InsightRepository.Reports.cs
index 8b3e8aa..3d0f2fb 100644
--- a/src/SQLInsight.Repository/InsightRepository.Reports.cs
+++ b/src/SQLInsight.Repository/InsightRepository.Reports.cs
@@ -35,3 +35,15 @@ public sealed partial class InsightRepository
""", new { from = Utc(from), to = Utc(to) }).ConfigureAwait(false);
}
}
+
+public sealed partial class InsightRepository
+{
+ /// Every rule id evaluated on some instance, with its title (local rules are only known this way).
+ public async Task> GetRuleTitlesAsync(CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
+ var rows = await connection.QueryAsync<(string RuleId, string Title)>(
+ "SELECT RuleId, MAX(RuleTitle) AS Title FROM insight.RuleStates GROUP BY RuleId;").ConfigureAwait(false);
+ return rows.ToList();
+ }
+}
diff --git a/src/SQLInsight.Web/Api/ApiTokens.cs b/src/SQLInsight.Web/Api/ApiTokens.cs
index 1b8fc77..bd670d4 100644
--- a/src/SQLInsight.Web/Api/ApiTokens.cs
+++ b/src/SQLInsight.Web/Api/ApiTokens.cs
@@ -7,15 +7,55 @@ using Microsoft.Extensions.Options;
namespace SQLInsight.Web.Api;
+/// A configured API token: only its SHA-256, and what it may do.
+public sealed record ApiToken(string Name, string Hash, IReadOnlyList Scopes)
+{
+ /// Read everything under /api/ and /metrics. The only scope of tokens in the old format.
+ public const string Read = "read";
+
+ /// Create and close support cases.
+ public const string SupportWrite = "support:write";
+
+ public static readonly IReadOnlyList KnownScopes = [Read, SupportWrite];
+
+ ///
+ /// SQLInsight:Web:ApiTokens: either the old form "name": "sha256:…" (read only) or
+ /// "name": { "hash": "sha256:…", "scopes": [ "read", "support:write" ] }.
+ ///
+ public static IReadOnlyList From(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ var tokens = new List();
+ foreach (var entry in configuration.GetSection(WebOptions.SectionName + ":ApiTokens").GetChildren())
+ {
+ if (entry.Value is { Length: > 0 } hash)
+ {
+ tokens.Add(new ApiToken(entry.Key, Normalize(hash), [Read]));
+ }
+ else if (entry["hash"] is { Length: > 0 } configured)
+ {
+ var scopes = entry.GetSection("scopes").GetChildren().Select(s => s.Value).OfType().ToList();
+ tokens.Add(new ApiToken(entry.Key, Normalize(configured), scopes.Count == 0 ? [Read] : scopes));
+ }
+ }
+
+ return tokens;
+ }
+
+ private static string Normalize(string hash) => (hash.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? hash["sha256:".Length..] : hash).ToUpperInvariant();
+}
+
///
-/// Bearer tokens for machines (Prometheus, Grafana, SCOM scripts) that cannot log in with Windows authentication.
-/// Only the SHA-256 of each token is configured, and a token only opens /api/ and /metrics, never the web pages.
+/// Bearer tokens for machines (Prometheus, Grafana, SCOM scripts, ITSM) that cannot log in with Windows authentication.
+/// Only the SHA-256 of each token is configured, and a token only opens /api/ and /metrics, never the web pages. What
+/// a token may do beyond reading is given by its scopes.
///
-public sealed class ApiTokenHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IOptions web)
+public sealed class ApiTokenHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IConfiguration configuration)
: AuthenticationHandler(options, logger, encoder)
{
public const string SchemeName = "ApiToken";
public const string ClaimType = "sqlinsight:api-token";
+ public const string ScopeClaimType = "sqlinsight:scope";
public static bool AppliesTo(PathString path) => path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) || path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase);
@@ -33,12 +73,13 @@ public sealed class ApiTokenHandler(IOptionsMonitor
}
var hash = Hash(header["Bearer ".Length..].Trim());
- foreach (var (name, expected) in web.Value.ApiTokens)
+ foreach (var token in ApiToken.From(configuration))
{
- var configured = expected.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ? expected["sha256:".Length..] : expected;
- if (configured.Length == hash.Length && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(configured.ToUpperInvariant()), Encoding.ASCII.GetBytes(hash)))
+ if (token.Hash.Length == hash.Length && CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(token.Hash), Encoding.ASCII.GetBytes(hash)))
{
- var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, "api:" + name), new Claim(ClaimType, name)], SchemeName);
+ var claims = new List { new(ClaimTypes.Name, "token:" + token.Name), new(ClaimType, token.Name) };
+ claims.AddRange(token.Scopes.Select(s => new Claim(ScopeClaimType, s)));
+ var identity = new ClaimsIdentity(claims, SchemeName);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)));
}
}
@@ -46,6 +87,16 @@ public sealed class ApiTokenHandler(IOptionsMonitor
return Task.FromResult(AuthenticateResult.Fail("Ukendt API-token."));
}
+ ///
+ /// Whether the caller may use a scope: a token needs it among its scopes; a Windows user with access to the web UI
+ /// (or anyone when authentication is off in development) may do what the UI lets them.
+ ///
+ public static bool HasScope(ClaimsPrincipal user, string scope)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ return !user.HasClaim(c => c.Type == ClaimType) || user.HasClaim(ScopeClaimType, scope);
+ }
+
public static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
/// A new random token and the line to put in appsettings.json.
@@ -55,3 +106,14 @@ public sealed class ApiTokenHandler(IOptionsMonitor
return (token, "sha256:" + Hash(token));
}
}
+
+public static class ApiScopes
+{
+ /// Reading needs the scope "read" for tokens (every token in the old format has it).
+ public static TBuilder RequireRead(this TBuilder builder)
+ where TBuilder : IEndpointConventionBuilder =>
+ builder.AddEndpointFilter(async (context, next) =>
+ HttpMethods.IsGet(context.HttpContext.Request.Method) && !ApiTokenHandler.HasScope(context.HttpContext.User, ApiToken.Read)
+ ? Results.Problem(statusCode: StatusCodes.Status403Forbidden, title: "Tokenet må ikke læse", detail: "API-tokenet mangler scope \"read\".")
+ : await next(context).ConfigureAwait(false));
+}
diff --git a/src/SQLInsight.Web/Api/ApiV1Endpoints.cs b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs
new file mode 100644
index 0000000..6319e5d
--- /dev/null
+++ b/src/SQLInsight.Web/Api/ApiV1Endpoints.cs
@@ -0,0 +1,524 @@
+using System.Globalization;
+using Microsoft.Extensions.Options;
+using SQLInsight.Core.Data;
+using SQLInsight.Core.Findings;
+using SQLInsight.Reports;
+using SQLInsight.Repository;
+
+namespace SQLInsight.Web.Api;
+
+///
+/// The read-only JSON API for findings, rules, reports and the pages of the web UI: the same data, windows and filters
+/// as the pages, built from the same models. Customer data (statement texts, logins, names) is sensitive and returned
+/// as "[udeladt]" unless SQLInsight:Web:ApiIncludesSensitiveEvidence is on.
+///
+public static class ApiV1Endpoints
+{
+ public const string Redacted = "[udeladt]";
+
+ private static readonly string[] ReportFormats = ["json", "pdf"];
+
+ /// Report types under /api/v1/reports/{type}.
+ public static readonly IReadOnlyList<(string Type, string Title, string Parameters)> ReportTypes =
+ [
+ ("driftsrapport", "Driftsrapport til ledelse og compliance", "days (1–366, standard 30)"),
+ ("analyse", "Analyse af tidsrum for én instans", "instance (påkrævet), from, to (ISO 8601; standard seneste time), sort (cpu|varighed|laesninger|koersler)"),
+ ("rettigheder", "Rettighedsrevision", "days (1–366, standard 90), instance (valgfri; standard alle)"),
+ ];
+
+ public static void Map(IEndpointRouteBuilder app)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
+
+ api.MapGet("/findings", SearchFindingsAsync)
+ .WithSummary("Fund: aktive, løste eller alle, med filtre og sider / Findings with filters and paging")
+ .WithDescription("Returns an array. More pages: the response header X-Continuation holds the value for ?continuation=.");
+ api.MapGet("/findings/{id:long}", FindingAsync).WithSummary("Ét fund med forklaring, anbefaling, målinger, fix-script og historik / One finding");
+ api.MapGet("/rules", RulesAsync).WithSummary("Regler: indbyggede, community og lokale / Rules");
+ api.MapGet("/instances/{name}/rule-states", RuleStatesAsync).WithSummary("Reglernes udfald på en instans / Rule outcomes on an instance");
+ api.MapGet("/reports", () => ReportTypes.Select(r => new { r.Type, r.Title, r.Parameters, Formats = ReportFormats }))
+ .WithSummary("Rapporttyper og deres parametre / Report types");
+ api.MapGet("/reports/{type}", ReportAsync).WithSummary("En rapport som JSON eller PDF (format=json|pdf) / A report as JSON or PDF");
+ api.MapGet("/instances/{name}/analysis", AnalysisAsync).WithSummary("Analyse af tidsrum / Time window analysis");
+ api.MapGet("/instances/{name}/top-sql", TopSqlAsync).WithSummary("Tungeste sætninger i et tidsrum / Top SQL in a window");
+ api.MapGet("/instances/{name}/disks", DisksAsync).WithSummary("Diskhastighed pr. drev med vurdering og tidsserier / Disk speed per drive");
+ api.MapGet("/instances/{name}/configuration", ConfigurationAsync).WithSummary("Konfiguration og ændringslog / Configuration and change log");
+ api.MapGet("/instances/{name}/privileges", InstancePrivilegesAsync).WithSummary("Rettigheder og ændringer på en instans / Privileges on an instance");
+ api.MapGet("/privileges", AllPrivilegesAsync).WithSummary("Rettigheder og ændringer på alle instanser / Privileges on all instances");
+ api.MapGet("/instances/{name}/wait-types/{waitType}", WaitTypeAsync).WithSummary("En ventetype: forklaring, udvikling og sætninger / One wait type");
+ }
+
+ private sealed class Redactor(bool include)
+ {
+ public bool Include => include;
+
+ public string? Value(string? value, bool sensitive = true) => sensitive && !include && value is not null ? Redacted : value;
+ }
+
+ private static Redactor RedactorFor(IOptions options) => new(options.Value.ApiIncludesSensitiveEvidence);
+
+ private static object FindingSummary(StoredFinding f, Redactor redact) => new
+ {
+ Id = f.FindingId,
+ Instance = f.InstanceName,
+ f.RuleId,
+ Category = Category(f.RuleId),
+ f.Subject,
+ Severity = f.Severity.ToString(),
+ f.Title,
+ f.Explanation,
+ f.Recommendation,
+ Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive) }),
+ f.FirstSeenAt,
+ f.LastSeenAt,
+ f.ResolvedAt,
+ };
+
+ /// SEC in SQLI-SEC-001, FT in COM-FT-002.
+ public static string Category(string ruleId)
+ {
+ ArgumentNullException.ThrowIfNull(ruleId);
+ var parts = ruleId.Split('-');
+ return parts.Length >= 3 ? parts[1] : ruleId;
+ }
+
+ private static async Task SearchFindingsAsync(HttpContext http, InsightRepository repository, IOptions options, CancellationToken ct,
+ string? status = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? instance = null, string? database = null,
+ string? ruleId = null, Severity? minSeverity = null, string? category = null, string? sort = null, int? limit = null, string? continuation = null)
+ {
+ var findingStatus = status?.ToLowerInvariant() switch
+ {
+ null or "" or "active" => (FindingStatus?)FindingStatus.Active,
+ "resolved" => FindingStatus.Resolved,
+ "all" => FindingStatus.All,
+ _ => null,
+ };
+ var findingSort = sort?.ToLowerInvariant() switch
+ {
+ null or "" or "severity" => (FindingSort?)FindingSort.Severity,
+ "firstseen" => FindingSort.FirstSeen,
+ "lastseen" => FindingSort.LastSeen,
+ "resolved" => FindingSort.Resolved,
+ _ => null,
+ };
+ if (findingStatus is null || findingSort is null)
+ {
+ return Results.BadRequest("status skal være active, resolved eller all; sort skal være severity, firstSeen, lastSeen eller resolved.");
+ }
+
+ var offset = 0;
+ if (continuation is { Length: > 0 } && !int.TryParse(continuation, NumberStyles.None, CultureInfo.InvariantCulture, out offset))
+ {
+ return Results.BadRequest("Ugyldig continuation.");
+ }
+
+ var size = Math.Clamp(limit ?? 100, 1, InsightRepository.MaxFindingPage);
+ var (findings, more) = await repository.SearchFindingsAsync(new FindingQuery
+ {
+ Status = findingStatus.Value,
+ From = from,
+ To = to,
+ Instance = instance,
+ Database = database,
+ RuleId = ruleId,
+ MinSeverity = minSeverity,
+ Category = category,
+ Sort = findingSort.Value,
+ Offset = offset,
+ Limit = size,
+ }, ct).ConfigureAwait(false);
+ if (more)
+ {
+ http.Response.Headers["X-Continuation"] = (offset + size).ToString(CultureInfo.InvariantCulture);
+ }
+
+ var redact = RedactorFor(options);
+ return Results.Ok(findings.Select(f => FindingSummary(f, redact)));
+ }
+
+ private static async Task FindingAsync(long id, InsightRepository repository, IOptions options, CancellationToken ct)
+ {
+ if (await repository.GetFindingAsync(id, ct).ConfigureAwait(false) is not { } f)
+ {
+ return Results.NotFound();
+ }
+
+ var redact = RedactorFor(options);
+ return Results.Ok(new
+ {
+ Id = f.FindingId,
+ Instance = f.InstanceName,
+ f.RuleId,
+ Category = Category(f.RuleId),
+ f.Subject,
+ Severity = f.Severity.ToString(),
+ f.Title,
+ f.Explanation,
+ f.Recommendation,
+ Evidence = f.Evidence.Select(e => new { e.Name, Value = redact.Value(e.Value, e.Sensitive), e.Sensitive }),
+ f.FixScript,
+ References = f.References.Select(r => new { r.Title, r.Url }),
+ History = new { f.FirstSeenAt, f.LastSeenAt, f.ResolvedAt, Status = f.ResolvedAt is null ? "active" : "resolved" },
+ });
+ }
+
+ private static async Task RulesAsync(InsightRepository repository, CancellationToken ct)
+ {
+ var builtIn = Rules.BuiltInRules.Create().Select(r => (r.Id, r.Title, Source: "indbygget"));
+ var community = Rules.Lifecycle.RulePack.Embedded().CommunityRules.Select(r => (r.Id, r.Title, Source: "community"));
+ var seen = (await repository.GetAllRuleStatesAsync(ct).ConfigureAwait(false)).Select(s => s.RuleId);
+ var known = builtIn.Concat(community).ToList();
+ var titles = (await repository.GetRuleTitlesAsync(ct).ConfigureAwait(false)).ToDictionary(t => t.RuleId, t => t.Title, StringComparer.OrdinalIgnoreCase);
+ var local = seen.Distinct(StringComparer.OrdinalIgnoreCase).Where(id => known.All(k => !string.Equals(k.Id, id, StringComparison.OrdinalIgnoreCase)))
+ .Select(id => (Id: id, Title: titles.GetValueOrDefault(id) ?? id, Source: id.StartsWith("COM-", StringComparison.Ordinal) ? "community" : "lokal"));
+ return Results.Ok(known.Concat(local).OrderBy(r => r.Id, StringComparer.Ordinal)
+ .Select(r => new { r.Id, r.Title, Category = Category(r.Id), r.Source }));
+ }
+
+ private static async Task RuleStatesAsync(string name, InsightRepository repository, CancellationToken ct)
+ {
+ if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
+ {
+ return Results.NotFound();
+ }
+
+ return Results.Ok((await repository.GetRuleStatesAsync(instance.InstanceId, ct).ConfigureAwait(false)).Select(s => new
+ {
+ s.RuleId,
+ s.RuleTitle,
+ Outcome = s.Outcome switch
+ {
+ Rules.Engine.RuleOutcome.Passed => "passed",
+ Rules.Engine.RuleOutcome.Findings => "findings",
+ Rules.Engine.RuleOutcome.NotMeasurable => "notMeasurable",
+ Rules.Engine.RuleOutcome.NotApplicable => "notApplicable",
+ _ => "error",
+ },
+ s.Reason,
+ s.EvaluatedAt,
+ }));
+ }
+
+ private static async Task ReportAsync(string type, InsightRepository repository, Display display, IOptions options, CancellationToken ct,
+ string? format = null, int? days = null, string? instance = null, DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null)
+ {
+ var pdf = string.Equals(format, "pdf", StringComparison.OrdinalIgnoreCase);
+ if (format is { Length: > 0 } && !pdf && !string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
+ {
+ return Results.BadRequest("format skal være json eller pdf.");
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var redact = RedactorFor(options);
+ switch (type.ToLowerInvariant())
+ {
+ case "driftsrapport":
+ {
+ var span = Math.Clamp(days ?? 30, 1, 366);
+ var data = await ReportData.LoadAsync(repository, options.Value.Organization, now.AddDays(-span), now, display.Zone, TimeProvider.System, ct).ConfigureAwait(false);
+ return pdf
+ ? Results.File(ManagementReport.Render(data), "application/pdf", $"driftsrapport-{now:yyyy-MM-dd}.pdf")
+ : Results.Ok(ManagementJson(data, redact));
+ }
+
+ case "analyse":
+ {
+ if (instance is null || await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false) is not { } target)
+ {
+ return Results.BadRequest("instance er påkrævet og skal være en kendt instans.");
+ }
+
+ var (start, end) = (from ?? now.AddHours(-1), to ?? now);
+ if (end <= start || end - start > TimeWindow.MaxLength)
+ {
+ return Results.BadRequest("to skal ligge efter from, og tidsrummet må højst være 31 dage.");
+ }
+
+ var analysis = await TimeWindowAnalysis.LoadAsync(repository, target, start, end, now, new RetentionOptions().RawDays, AnalysisOrder.Parse(sort), ct).ConfigureAwait(false);
+ return pdf
+ ? Results.File(TimeWindowReport.Render(analysis, options.Value.Organization, display.Zone), "application/pdf", $"analyse-{now:yyyy-MM-dd}.pdf")
+ : Results.Ok(AnalysisJson(analysis, redact));
+ }
+
+ case "rettigheder":
+ {
+ InstanceOverview? target = null;
+ if (instance is { Length: > 0 } && (target = await repository.GetInstanceAsync(instance, ct).ConfigureAwait(false)) is null)
+ {
+ return Results.NotFound();
+ }
+
+ var span = Math.Clamp(days ?? 90, 1, 366);
+ var audit = await PrivilegeAudit.LoadAsync(repository, target, now.AddDays(-span), now, ct).ConfigureAwait(false);
+ return pdf
+ ? Results.File(audit.Render(options.Value.Organization, display.Zone), "application/pdf", $"rettigheder-{now:yyyy-MM-dd}.pdf")
+ : Results.Ok(AuditJson(audit, redact));
+ }
+
+ default:
+ return Results.NotFound();
+ }
+ }
+
+ private static object ManagementJson(ReportData data, Redactor redact) => new
+ {
+ data.Organization,
+ data.From,
+ data.To,
+ data.GeneratedAt,
+ TimeZone = data.TimeZone.Id,
+ data.Version,
+ data.RulePack,
+ Summary = ManagementReport.SummarySentences(data),
+ Period = data.Period,
+ Controls = ComplianceControl.All.Select(c => new
+ {
+ c.Name,
+ c.Description,
+ c.Rules,
+ Instances = data.Instances.Select(i => new { Instance = i.Name, Status = c.StatusOn(i.Name, data.RuleStates).ToString() }),
+ }),
+ Instances = data.Instances.Select(i => new
+ {
+ i.Name, i.ProductVersion, i.ProductLevel, i.ProductUpdateLevel, i.Edition, i.LastSeenAt, i.IsFailing,
+ Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
+ }),
+ Findings = data.Active.Select(f => FindingSummary(f, redact)),
+ ConfigurationChanges = data.ConfigurationChanges.Select(c => ConfigurationChangeJson(c, redact)),
+ };
+
+ private static object ConfigurationChangeJson(StoredConfigurationChange c, Redactor redact)
+ {
+ // Service accounts are logins: sensitive, as in the configuration rule's evidence.
+ var sensitive = c.Area == ConfigurationFingerprint.ServiceAccount;
+ return new { Instance = c.InstanceName, c.DetectedAt, c.Area, c.Name, OldValue = redact.Value(c.OldValue, sensitive), NewValue = redact.Value(c.NewValue, sensitive) };
+ }
+
+ private static object AnalysisJson(TimeWindowAnalysis a, Redactor redact) => new
+ {
+ Instance = a.Instance.Name,
+ a.From,
+ a.To,
+ Resolution = a.Resolution.ToString(),
+ a.HasData,
+ Metrics = a.Metrics.Select(m => new
+ {
+ m.Metric, m.Label, m.Unit, m.Average, m.Peak, m.NormalAverage, m.IsUnusual,
+ Assessment = AssessmentJson(a.Assess(m)),
+ Explanation = MetricAssessment.Explanation(m.Metric),
+ Points = m.Points,
+ }),
+ Waits = a.Waits.Select(w => new { w.WaitType, Category = w.Category.ToString(), w.WaitTimeMs, w.WaitingTasks, w.PerMinute, w.NormalPerMinute, w.Share, w.IsUnusual }),
+ TopQueries = a.TopQueries.Select(q => TopQueryJson(q, a.ActivityPercent(q), redact)),
+ Disks = a.Disks.Select(d => DiskJson(d.Window, d.Normal)),
+ Files = a.Files,
+ Blocking = a.Blocking.Select(b => new
+ {
+ b.SampledAt, b.HeadSessionId, b.BlockedCount, b.MaxWaitMs,
+ Sessions = b.Sessions.Select(s => new
+ {
+ s.SessionId, s.BlockingSessionId, s.Status, s.WaitType, s.WaitTimeMs, s.DatabaseName,
+ LoginName = redact.Value(s.LoginName), HostName = redact.Value(s.HostName), ProgramName = redact.Value(s.ProgramName), SqlText = redact.Value(s.SqlText),
+ }),
+ }),
+ Deadlocks = a.Deadlocks.Select(d => new { d.DeadlockId, d.OccurredAt, Processes = d.Processes.Count, Graph = $"/deadlocks/{d.DeadlockId}.xdl" }),
+ LongOperations = a.LongOperations.Select(o => new { o.Command, o.DatabaseName, LoginName = redact.Value(o.LoginName), o.StartedAt, o.LastSeenAt, o.PercentComplete, o.EstimatedEnd }),
+ Findings = a.Findings.Select(f => FindingSummary(f, redact)),
+ };
+
+ private static object AssessmentJson(Assessment a) => new { Level = a.Level.ToString(), Text = a.LevelText, a.Summary, a.NextStep, a.Link };
+
+ private static object TopQueryJson(TopQuery q, double? activity, Redactor redact) => new
+ {
+ q.QueryKey, q.DatabaseName, Text = redact.Value(q.Text), q.Executions, q.WorkerTimeUs, q.ElapsedTimeUs, q.LogicalReads, q.PhysicalReads, q.LogicalWrites,
+ ActivityPercent = activity,
+ };
+
+ private static object DiskJson(DiskActivity w, DiskActivity? normal) => new
+ {
+ w.Volume, w.DataFiles, w.LogFiles, w.ReadIops, w.WriteIops, w.ReadMbPerSecond, w.WriteMbPerSecond, w.AverageReadMs, w.AverageWriteMs,
+ Normal = normal is null ? null : new { normal.AverageReadMs, normal.AverageWriteMs, normal.ReadIops, normal.WriteIops },
+ Assessment = AssessmentJson(MetricAssessment.AssessDisk(w)),
+ };
+
+ private static object AuditJson(PrivilegeAudit audit, Redactor redact) => new
+ {
+ audit.From,
+ audit.To,
+ Method = PrivilegeAudit.MethodText,
+ Instances = audit.Instances.Select(e => InstanceAuditJson(e, redact)),
+ };
+
+ private static object InstanceAuditJson(PrivilegeAuditInstance e, Redactor redact) => new
+ {
+ Instance = e.Instance.Name,
+ Measured = e.Snapshot?.CapturedAt,
+ SysadminCount = e.Snapshot is null ? (int?)null : e.Sysadmins.Count,
+ MaxSysadminAccounts = e.Snapshot?.MaxSysadminAccounts,
+ Sysadmins = e.Sysadmins.Select(a => new { Account = redact.Value(a.Account), DisplayName = redact.Value(a.DisplayName), a.Type, Source = a.Source == PrivilegeAnalysis.Direct ? a.Source : redact.Value(a.Source) }),
+ Principals = (e.Snapshot?.Principals ?? []).Select(p => new
+ {
+ Name = redact.Value(p.Name), p.Type, p.Privilege, p.IsDisabled, p.ExpansionError,
+ Members = p.Members?.Select(m => new { Account = redact.Value(m.Account), DisplayName = redact.Value(m.DisplayName), m.Enabled }),
+ }),
+ Sa = e.Snapshot is null ? null : PrivilegeAudit.SaText(e.Sa),
+ LoginsWithoutPolicy = e.LoginsWithoutPolicy.Select(l => redact.Value(l.Name)),
+ DefaultTrace = e.Snapshot is null ? null : PrivilegeAudit.TraceText(e.Snapshot),
+ Changes = e.Changes.Select(c => new
+ {
+ c.DetectedAt, c.Kind, c.Privilege, Principal = redact.Value(c.Principal), c.PrincipalType, ViaGroup = redact.Value(c.ViaGroup),
+ ChangedBy = redact.Value(c.ChangedBy), c.ChangedAt,
+ }),
+ };
+
+ private static async Task<(InstanceOverview? Instance, DateTimeOffset From, DateTimeOffset To, IResult? Error)> WindowAsync(
+ string name, InsightRepository repository, DateTimeOffset? from, DateTimeOffset? to, TimeSpan defaultLength, CancellationToken ct)
+ {
+ var instance = await repository.GetInstanceAsync(name, ct).ConfigureAwait(false);
+ var now = DateTimeOffset.UtcNow;
+ var (start, end) = (from ?? (to ?? now) - defaultLength, to ?? now);
+ IResult? error = instance is null ? Results.NotFound()
+ : end <= start || end - start > TimeWindow.MaxLength ? Results.BadRequest("to skal ligge efter from, og tidsrummet må højst være 31 dage.")
+ : null;
+ return (instance, start, end, error);
+ }
+
+ private static async Task AnalysisAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct,
+ DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null)
+ {
+ var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(1), ct).ConfigureAwait(false);
+ if (error is not null)
+ {
+ return error;
+ }
+
+ var analysis = await TimeWindowAnalysis.LoadAsync(repository, instance!, start, end, DateTimeOffset.UtcNow, new RetentionOptions().RawDays, AnalysisOrder.Parse(sort), ct)
+ .ConfigureAwait(false);
+ return Results.Ok(AnalysisJson(analysis, RedactorFor(options)));
+ }
+
+ private static async Task TopSqlAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct,
+ DateTimeOffset? from = null, DateTimeOffset? to = null, string? sort = null, int? limit = null)
+ {
+ var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
+ if (error is not null)
+ {
+ return error;
+ }
+
+ var seconds = (end - start).TotalSeconds;
+ var queries = await repository.GetTopQueriesAsync(instance!.InstanceId, start, end, AnalysisOrder.Parse(sort), Math.Clamp(limit ?? 25, 1, 200), ct).ConfigureAwait(false);
+ var redact = RedactorFor(options);
+ return Results.Ok(new
+ {
+ Instance = instance.Name,
+ From = start,
+ To = end,
+ Queries = queries.Select(q => TopQueryJson(q, 100.0 * q.WorkerTimeUs / (seconds * 1_000_000), redact)),
+ });
+ }
+
+ private static async Task DisksAsync(string name, InsightRepository repository, CancellationToken ct, DateTimeOffset? from = null, DateTimeOffset? to = null)
+ {
+ var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
+ if (error is not null)
+ {
+ return error;
+ }
+
+ var hourly = TimeWindow.ChooseResolution(start, DateTimeOffset.UtcNow, new RetentionOptions().RawDays) == WindowResolution.Hourly;
+ var disks = hourly
+ ? await repository.GetDiskActivityHourlyAsync(instance!.InstanceId, start, end, ct).ConfigureAwait(false)
+ : await repository.GetDiskActivityAsync(instance!.InstanceId, start, end, ct).ConfigureAwait(false);
+ var series = await repository.GetDiskSeriesAsync(instance.InstanceId, start, ct).ConfigureAwait(false);
+ return Results.Ok(new
+ {
+ Instance = instance.Name,
+ From = start,
+ To = end,
+ Resolution = hourly ? "Hourly" : "Raw",
+ Explanation = new { Read = MetricAssessment.Explanation("disk.read"), LogWrite = MetricAssessment.Explanation("disk.write.log"), Write = MetricAssessment.Explanation("disk.write") },
+ Drives = disks.Select(d => DiskJson(d, null)),
+ Series = series,
+ });
+ }
+
+ private static async Task ConfigurationAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct, int? days = null)
+ {
+ if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
+ {
+ return Results.NotFound();
+ }
+
+ var redact = RedactorFor(options);
+ var id = instance.InstanceId;
+ var setup = await repository.GetSnapshotAsync(id, "instance-config", ct).ConfigureAwait(false);
+ var configuration = await repository.GetSnapshotAsync(id, "configuration", ct).ConfigureAwait(false);
+ var traceFlags = await repository.GetSnapshotAsync(id, "trace-flags", ct).ConfigureAwait(false);
+ var now = DateTimeOffset.UtcNow;
+ var changes = await repository.GetConfigurationChangesAsync(id, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, 500, ct).ConfigureAwait(false);
+ return Results.Ok(new
+ {
+ Instance = instance.Name,
+ Setup = setup is null ? null : new
+ {
+ setup.CapturedAt, setup.ProductVersion, setup.ProductLevel, setup.Edition, setup.Collation, setup.ServicesAvailable,
+ Services = setup.Services.Select(s => new { s.Name, Account = redact.Value(s.Account), s.StartupType, s.Status, s.InstantFileInitialization }),
+ setup.InstantFileInitialization, setup.MemoryModel, setup.TempdbFiles, setup.DefaultDataPath, setup.DefaultLogPath,
+ },
+ Options = configuration?.Options.Select(o => new { o.Name, o.Value, o.ValueInUse, Default = ConfigurationFingerprint.DefaultValue(o.Name) }),
+ TraceFlags = traceFlags?.GlobalTraceFlags,
+ Changes = changes.Select(c => ConfigurationChangeJson(c, redact)),
+ });
+ }
+
+ private static async Task InstancePrivilegesAsync(string name, InsightRepository repository, IOptions options, CancellationToken ct, int? days = null)
+ {
+ if (await repository.GetInstanceAsync(name, ct).ConfigureAwait(false) is not { } instance)
+ {
+ return Results.NotFound();
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var audit = await PrivilegeAudit.LoadAsync(repository, instance, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, ct).ConfigureAwait(false);
+ return Results.Ok(InstanceAuditJson(audit.Instances[0], RedactorFor(options)));
+ }
+
+ private static async Task AllPrivilegesAsync(InsightRepository repository, IOptions options, CancellationToken ct, int? days = null)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var audit = await PrivilegeAudit.LoadAsync(repository, null, now.AddDays(-Math.Clamp(days ?? 90, 1, 366)), now, ct).ConfigureAwait(false);
+ return Results.Ok(AuditJson(audit, RedactorFor(options)));
+ }
+
+ private static async Task WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions options, CancellationToken ct,
+ DateTimeOffset? from = null, DateTimeOffset? to = null)
+ {
+ var (instance, start, end, error) = await WindowAsync(name, repository, from, to, TimeSpan.FromHours(24), ct).ConfigureAwait(false);
+ if (error is not null)
+ {
+ return error;
+ }
+
+ var type = waitType.Trim().ToUpperInvariant();
+ var explanation = WaitKnowledge.Lookup(type);
+ var hourly = TimeWindow.ChooseResolution(start, DateTimeOffset.UtcNow, new RetentionOptions().RawDays) == WindowResolution.Hourly;
+ var series = await repository.GetWaitTypeSeriesAsync(instance!.InstanceId, type, start, end, hourly, ct).ConfigureAwait(false);
+ var statements = await repository.GetWaitTypeStatementsAsync(instance.InstanceId, type, start, end, 25, ct).ConfigureAwait(false);
+ var redact = RedactorFor(options);
+ return Results.Ok(new
+ {
+ Instance = instance.Name,
+ WaitType = type,
+ From = start,
+ To = end,
+ Explanation = new { explanation.Title, explanation.Meaning, explanation.Normal, explanation.Causes, explanation.Actions, explanation.DynamicsAx, explanation.Known },
+ WaitMsPerMinute = series,
+ Statements = statements.Select(s => new
+ {
+ s.QueryKey, s.DatabaseName, Text = redact.Value(s.Text), s.Samples, s.Share, s.EstimatedWaitSeconds, s.AverageWaitMs, s.MaxWaitMs, s.BlockedSamples,
+ }),
+ });
+ }
+}
diff --git a/src/SQLInsight.Web/Api/IntegrationEndpoints.cs b/src/SQLInsight.Web/Api/IntegrationEndpoints.cs
index 30e52a5..c113c68 100644
--- a/src/SQLInsight.Web/Api/IntegrationEndpoints.cs
+++ b/src/SQLInsight.Web/Api/IntegrationEndpoints.cs
@@ -16,7 +16,7 @@ public static class IntegrationEndpoints
public static void Map(IEndpointRouteBuilder app)
{
- var api = app.MapGroup("/api/v1").DisableAntiforgery();
+ var api = app.MapGroup("/api/v1").DisableAntiforgery().WithTags("SQLInsight Pro API v1").RequireRead();
api.MapGet("/instances", async (InsightRepository repository, CancellationToken ct) =>
(await repository.GetInstancesAsync(ct).ConfigureAwait(false)).Select(i => new
{
@@ -30,33 +30,11 @@ public static class IntegrationEndpoints
i.LastError,
Findings = new { i.Critical, i.High, i.Medium, i.Low, i.Info },
i.NotMeasurable,
- }));
-
- api.MapGet("/findings", async (string? instance, Severity? minSeverity, InsightRepository repository, Microsoft.Extensions.Options.IOptions options, CancellationToken ct) =>
- {
- var findings = await repository.GetActiveFindingsAsync(cancellationToken: ct).ConfigureAwait(false);
- var includeSensitive = options.Value.ApiIncludesSensitiveEvidence;
- return findings
- .Where(f => instance is null || string.Equals(f.InstanceName, instance, StringComparison.OrdinalIgnoreCase))
- .Where(f => minSeverity is null || f.Severity >= minSeverity)
- .Select(f => new
- {
- Id = f.FindingId,
- Instance = f.InstanceName,
- f.RuleId,
- f.Subject,
- Severity = f.Severity.ToString(),
- f.Title,
- f.Explanation,
- f.Recommendation,
- Evidence = f.Evidence.Select(e => new { e.Name, Value = e.Sensitive && !includeSensitive ? "[udeladt]" : e.Value }),
- f.FirstSeenAt,
- f.LastSeenAt,
- });
- });
+ }))
+ .WithSummary("Instanser med status og antal fund / Instances");
app.MapGet("/metrics", async (InsightRepository repository, CancellationToken ct) =>
- Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8"));
+ Results.Text(await PrometheusAsync(repository, ct).ConfigureAwait(false), "text/plain; version=0.0.4; charset=utf-8")).RequireRead();
}
private static bool IsUp(InstanceOverview i) => !i.IsFailing && i.LastSeenAt is { } seen && DateTime.UtcNow - seen < UpWindow;
diff --git a/src/SQLInsight.Web/Program.cs b/src/SQLInsight.Web/Program.cs
index 3097a8f..edca889 100644
--- a/src/SQLInsight.Web/Program.cs
+++ b/src/SQLInsight.Web/Program.cs
@@ -9,9 +9,20 @@ if (args.FirstOrDefault() == "new-api-token")
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
var (token, hash) = ApiTokenHandler.Create();
- var name = args.ElementAtOrDefault(1) ?? "prometheus";
+ var name = args.Skip(1).FirstOrDefault(a => !a.StartsWith("--", StringComparison.Ordinal)) ?? "prometheus";
+ var scopeAt = Array.IndexOf(args, "--scope");
+ var scopes = scopeAt > 0 && scopeAt + 1 < args.Length ? args[scopeAt + 1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : [ApiToken.Read];
+ var unknown = scopes.Except(ApiToken.KnownScopes).ToList();
+ if (unknown.Count > 0)
+ {
+ Console.Error.WriteLine($"Ukendt scope: {string.Join(", ", unknown)}. Mulige: {string.Join(", ", ApiToken.KnownScopes)}.");
+ return;
+ }
+
Console.WriteLine($"Token (vises kun nu, gem det i det system, der skal hente data): {token}");
- Console.WriteLine($"Tilføj i appsettings.json under SQLInsight:Web:ApiTokens: \"{name}\": \"{hash}\"");
+ Console.WriteLine(scopes.SequenceEqual([ApiToken.Read])
+ ? $"Tilføj i appsettings.json under SQLInsight:Web:ApiTokens: \"{name}\": \"{hash}\""
+ : $"Tilføj i appsettings.json under SQLInsight:Web:ApiTokens: \"{name}\": {{ \"hash\": \"{hash}\", \"scopes\": [ {string.Join(", ", scopes.Select(s => $"\"{s}\""))} ] }}");
return;
}
@@ -39,6 +50,9 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All));
builder.Services.AddRazorComponents();
+// OpenAPI description of /api/v1, served at /api/v1/openapi.json behind the same authentication.
+builder.Services.AddOpenApi("v1", o => o.ShouldInclude = d => d.RelativePath?.StartsWith("api/v1", StringComparison.OrdinalIgnoreCase) == true);
+
var windowsAuth = string.Equals(web.Authentication, "Windows", StringComparison.OrdinalIgnoreCase);
if (windowsAuth)
{
@@ -100,6 +114,8 @@ app.MapGet("/deadlocks/{id:long}.xdl", async (long id, InsightRepository reposit
: Results.NotFound());
IntegrationEndpoints.Map(app);
+ApiV1Endpoints.Map(app);
+app.MapOpenApi("/api/v1/openapi.json").RequireRead();
Support.Map(app);
// Management and compliance report as PDF, for the last N days.
diff --git a/src/SQLInsight.Web/SQLInsight.Web.csproj b/src/SQLInsight.Web/SQLInsight.Web.csproj
index 67dde4b..ecdada1 100644
--- a/src/SQLInsight.Web/SQLInsight.Web.csproj
+++ b/src/SQLInsight.Web/SQLInsight.Web.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/SQLInsight.Web/WebOptions.cs b/src/SQLInsight.Web/WebOptions.cs
index 7606106..09ed4bd 100644
--- a/src/SQLInsight.Web/WebOptions.cs
+++ b/src/SQLInsight.Web/WebOptions.cs
@@ -13,10 +13,6 @@ public sealed class WebOptions
/// Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.
public string? TimeZone { get; set; }
- ///
- /// Tokens for /api/ and /metrics (name → "sha256:<hex>"). Create one with SQLInsight.Web new-api-token <name>.
- ///
- public IDictionary ApiTokens { get; } = new Dictionary();
/// Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default.
public bool ApiIncludesSensitiveEvidence { get; set; }
diff --git a/tests/SQLInsight.IntegrationTests/WebApiTests.cs b/tests/SQLInsight.IntegrationTests/WebApiTests.cs
index 9d2976c..81b79ea 100644
--- a/tests/SQLInsight.IntegrationTests/WebApiTests.cs
+++ b/tests/SQLInsight.IntegrationTests/WebApiTests.cs
@@ -3,6 +3,8 @@ using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using SQLInsight.Core.Data;
using SQLInsight.Core.Findings;
using SQLInsight.Core.Instances;
using SQLInsight.Repository;
@@ -135,3 +137,164 @@ public class WebApiTests(SqlServerFixture sql) : IAsyncLifetime
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
+
+/// The /api/v1 read endpoints for findings, rules, reports and the pages, with paging, redaction and OpenAPI.
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime
+{
+ private readonly string _database = "SQLInsightApiV1_" + Guid.NewGuid().ToString("N")[..8];
+ private WebApplicationFactory _factory = null!;
+ private InsightRepository _repository = null!;
+ private int _instanceId;
+ private const string Name = @"AX01\AX30";
+
+ public async Task InitializeAsync()
+ {
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
+ _repository = new InsightRepository(sql.DatabaseConnectionString(_database));
+ await _repository.MigrateAsync();
+ var now = DateTimeOffset.UtcNow;
+ _instanceId = await _repository.UpsertInstanceAsync(Name, new InstanceInfo
+ {
+ ServerName = "AX01", Version = SqlServerVersion.Parse("16.0.4135.4"), ProductLevel = "RTM", Edition = "Standard Edition", EngineEdition = EngineEdition.Standard,
+ }, "SqlClient", false, now);
+ var findings = Enumerable.Range(1, 5).Select(i => new Finding
+ {
+ RuleId = "SQLI-DB-007", Severity = i == 1 ? Severity.Critical : Severity.Medium, Title = $"Ingen backup af Db{i}", Explanation = "e", Recommendation = "r",
+ Subject = $"database: Db{i}", FixScript = "BACKUP DATABASE …",
+ Evidence = [new Evidence("Database", $"Db{i}"), new Evidence("Login", @"KUNDE\anne", Sensitive: true)],
+ }).ToArray();
+ await _repository.SaveRuleResultsAsync(_instanceId,
+ [new RuleResult { RuleId = "SQLI-DB-007", RuleTitle = "Fuld backup", Outcome = RuleOutcome.Findings, Findings = findings },
+ new RuleResult { RuleId = "LOCAL-AX-001", RuleTitle = "Lokal regel", Outcome = RuleOutcome.Passed }], now);
+ await _repository.SaveCollectedAsync(_instanceId, "wait-sampling", WaitSampling.Aggregate(
+ [new WaitingRequest(60, "LCK_M_U", 3000, "AXDB", "0xAB", "UPDATE NUMBERSEQUENCETABLE SET NEXTREC = 1", 55)], now.AddMinutes(-1)));
+
+ _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()
+ {
+ await _factory.DisposeAsync();
+ await sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
+ }
+
+ private async Task GetJsonAsync(string path)
+ {
+ var response = await _factory.CreateClient().GetAsync(path);
+ Assert.True(response.IsSuccessStatusCode, $"{path}: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
+ return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone();
+ }
+
+ [Fact]
+ public async Task Findings_are_filtered_and_paged_with_a_continuation()
+ {
+ var client = _factory.CreateClient();
+ var first = await client.GetAsync("/api/v1/findings?status=all&instance=" + Uri.EscapeDataString(Name) + "&limit=2&sort=firstSeen");
+ var continuation = first.Headers.GetValues("X-Continuation").Single();
+ var page1 = JsonDocument.Parse(await first.Content.ReadAsStringAsync()).RootElement;
+ var page2 = await GetJsonAsync($"/api/v1/findings?status=all&limit=2&sort=firstSeen&continuation={continuation}");
+ var critical = await GetJsonAsync("/api/v1/findings?minSeverity=Critical");
+ var byDatabase = await GetJsonAsync("/api/v1/findings?database=Db3&category=DB");
+ var resolved = await GetJsonAsync("/api/v1/findings?status=resolved");
+
+ Assert.Equal(2, page1.GetArrayLength());
+ Assert.Equal(2, page2.GetArrayLength());
+ Assert.NotEqual(page1[0].GetProperty("id").GetInt64(), page2[0].GetProperty("id").GetInt64());
+ Assert.Equal("Ingen backup af Db1", Assert.Single(critical.EnumerateArray()).GetProperty("title").GetString());
+ Assert.Equal("database: Db3", Assert.Single(byDatabase.EnumerateArray()).GetProperty("subject").GetString());
+ Assert.Equal(0, resolved.GetArrayLength());
+ Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/v1/findings?status=nogle")).StatusCode);
+ }
+
+ [Fact]
+ public async Task One_finding_has_explanation_fix_script_history_and_no_sensitive_values()
+ {
+ var list = await GetJsonAsync("/api/v1/findings?minSeverity=Critical");
+ var id = list[0].GetProperty("id").GetInt64();
+
+ var finding = await GetJsonAsync($"/api/v1/findings/{id}");
+ var text = finding.GetRawText();
+
+ Assert.Equal("BACKUP DATABASE …", finding.GetProperty("fixScript").GetString());
+ Assert.Equal("active", finding.GetProperty("history").GetProperty("status").GetString());
+ Assert.Contains("[udeladt]", text, StringComparison.Ordinal);
+ Assert.DoesNotContain("anne", text, StringComparison.Ordinal);
+ Assert.Equal(HttpStatusCode.NotFound, (await _factory.CreateClient().GetAsync("/api/v1/findings/999999")).StatusCode);
+ }
+
+ [Fact]
+ public async Task Rules_and_rule_states_list_built_in_community_and_local_rules()
+ {
+ var rules = await GetJsonAsync("/api/v1/rules");
+ var states = await GetJsonAsync($"/api/v1/instances/{Uri.EscapeDataString(Name)}/rule-states");
+
+ Assert.Contains(rules.EnumerateArray(), r => r.GetProperty("id").GetString() == "SQLI-SEC-001" && r.GetProperty("source").GetString() == "indbygget");
+ Assert.Contains(rules.EnumerateArray(), r => r.GetProperty("id").GetString() == "COM-FT-005" && r.GetProperty("source").GetString() == "community");
+ Assert.Contains(rules.EnumerateArray(), r => r.GetProperty("id").GetString() == "LOCAL-AX-001" && r.GetProperty("source").GetString() == "lokal");
+ Assert.Contains(states.EnumerateArray(), s => s.GetProperty("ruleId").GetString() == "SQLI-DB-007" && s.GetProperty("outcome").GetString() == "findings");
+ }
+
+ [Theory]
+ [InlineData("driftsrapport", "")]
+ [InlineData("analyse", "&instance=AX01%5CAX30")]
+ [InlineData("rettigheder", "&instance=AX01%5CAX30")]
+ public async Task Every_report_comes_as_json_and_pdf(string type, string parameters)
+ {
+ var client = _factory.CreateClient();
+ var json = await GetJsonAsync($"/api/v1/reports/{type}?format=json{parameters}");
+ var pdf = await client.GetAsync($"/api/v1/reports/{type}?format=pdf{parameters}");
+ var list = await GetJsonAsync("/api/v1/reports");
+
+ Assert.Equal(JsonValueKind.Object, json.ValueKind);
+ Assert.Equal("application/pdf", pdf.Content.Headers.ContentType?.MediaType);
+ Assert.Contains(list.EnumerateArray(), r => r.GetProperty("type").GetString() == type);
+ }
+
+ [Fact]
+ public async Task Page_data_is_served_with_statement_texts_left_out()
+ {
+ var name = Uri.EscapeDataString(Name);
+ var waits = await GetJsonAsync($"/api/v1/instances/{name}/wait-types/LCK_M_U");
+ var analysis = await GetJsonAsync($"/api/v1/instances/{name}/analysis");
+ var topSql = await GetJsonAsync($"/api/v1/instances/{name}/top-sql");
+ var disks = await GetJsonAsync($"/api/v1/instances/{name}/disks");
+ var configuration = await GetJsonAsync($"/api/v1/instances/{name}/configuration?days=30");
+ var privileges = await GetJsonAsync($"/api/v1/instances/{name}/privileges");
+ var all = await GetJsonAsync("/api/v1/privileges?days=7");
+
+ Assert.Equal("Venter på en lås", waits.GetProperty("explanation").GetProperty("title").GetString());
+ var statement = Assert.Single(waits.GetProperty("statements").EnumerateArray());
+ Assert.Equal("[udeladt]", statement.GetProperty("text").GetString());
+ Assert.Equal(1, statement.GetProperty("samples").GetInt64());
+ Assert.True(analysis.TryGetProperty("metrics", out _));
+ Assert.True(topSql.TryGetProperty("queries", out _));
+ Assert.True(disks.TryGetProperty("drives", out _));
+ Assert.True(configuration.TryGetProperty("changes", out _));
+ Assert.Equal(Name, privileges.GetProperty("instance").GetString());
+ Assert.Equal(1, all.GetProperty("instances").GetArrayLength());
+ }
+
+ [Fact]
+ public async Task Openapi_describes_every_api_v1_endpoint()
+ {
+ var document = await GetJsonAsync("/api/v1/openapi.json");
+ var described = document.GetProperty("paths").EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var endpoints = _factory.Services.GetRequiredService().Endpoints
+ .OfType()
+ .Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/'))
+ .Where(p => p.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase) && !p.EndsWith("openapi.json", StringComparison.OrdinalIgnoreCase))
+ .Select(p => System.Text.RegularExpressions.Regex.Replace(p, @"\{([^}:]+)(:[^}]+)?\}", "{$1}"))
+ .Distinct()
+ .ToList();
+
+ Assert.NotEmpty(endpoints);
+ Assert.All(endpoints, e => Assert.Contains(e, described));
+ }
+}