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