API v1: findings, rules, reports and page data as JSON, with OpenAPI
- /api/v1/findings gains status (active/resolved/all), from/to, database,
ruleId, category, sort and paging (limit + X-Continuation header; the
response stays an array). /findings/{id} with fix script and history.
- /rules (built-in, community, local) and /instances/{name}/rule-states.
- /reports and /reports/{driftsrapport|analyse|rettigheder} as JSON or PDF,
built from the same models as the pages and PDFs.
- Page data: analysis, top-sql, disks, configuration, privileges (one or
all instances) and wait types. Statement texts, logins, names and
service accounts are "[udeladt]" unless ApiIncludesSensitiveEvidence.
- OpenAPI at /api/v1/openapi.json; a test fails when an endpoint is
missing from it.
- API tokens get scopes ("read", "support:write"); the old form is read
only, and new-api-token takes --scope. Token names show as token:<name>.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cc71fb12c6
commit
30d89acbd7
11 changed files with 938 additions and 43 deletions
|
|
@ -28,6 +28,7 @@
|
|||
<PackageVersion Include="Microsoft.Extensions.Hosting.Systemd" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Negotiate" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
|
|
|
|||
|
|
@ -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 <token>`. Brug et token med kun `read`.
|
||||
|
||||
## SCOM og Windows' hændelseslog
|
||||
|
||||
|
|
|
|||
97
src/SQLInsight.Repository/InsightRepository.FindingSearch.cs
Normal file
97
src/SQLInsight.Repository/InsightRepository.FindingSearch.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using Dapper;
|
||||
using SQLInsight.Core.Findings;
|
||||
|
||||
namespace SQLInsight.Repository;
|
||||
|
||||
public enum FindingStatus
|
||||
{
|
||||
Active,
|
||||
Resolved,
|
||||
All,
|
||||
}
|
||||
|
||||
public enum FindingSort
|
||||
{
|
||||
/// <summary>Most severe first, then oldest.</summary>
|
||||
Severity,
|
||||
FirstSeen,
|
||||
LastSeen,
|
||||
Resolved,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A search for findings. <see cref="From"/>/<see cref="To"/> select by when they arose (active), were resolved
|
||||
/// (resolved), or either (all). <see cref="Category"/> is the middle part of the rule id (SEC in SQLI-SEC-001).
|
||||
/// <see cref="Database"/> matches findings whose subject names the database.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>One page of findings matching <paramref name="query"/>, and whether there are more.</summary>
|
||||
public async Task<(IReadOnlyList<StoredFinding> 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<FindingRow>($"""
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -35,3 +35,15 @@ public sealed partial class InsightRepository
|
|||
""", new { from = Utc(from), to = Utc(to) }).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class InsightRepository
|
||||
{
|
||||
/// <summary>Every rule id evaluated on some instance, with its title (local rules are only known this way).</summary>
|
||||
public async Task<IReadOnlyList<(string RuleId, string Title)>> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,15 +7,55 @@ using Microsoft.Extensions.Options;
|
|||
|
||||
namespace SQLInsight.Web.Api;
|
||||
|
||||
/// <summary>A configured API token: only its SHA-256, and what it may do.</summary>
|
||||
public sealed record ApiToken(string Name, string Hash, IReadOnlyList<string> Scopes)
|
||||
{
|
||||
/// <summary>Read everything under /api/ and /metrics. The only scope of tokens in the old format.</summary>
|
||||
public const string Read = "read";
|
||||
|
||||
/// <summary>Create and close support cases.</summary>
|
||||
public const string SupportWrite = "support:write";
|
||||
|
||||
public static readonly IReadOnlyList<string> KnownScopes = [Read, SupportWrite];
|
||||
|
||||
/// <summary>
|
||||
/// SQLInsight:Web:ApiTokens: either the old form <c>"name": "sha256:…"</c> (read only) or
|
||||
/// <c>"name": { "hash": "sha256:…", "scopes": [ "read", "support:write" ] }</c>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ApiToken> From(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
var tokens = new List<ApiToken>();
|
||||
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<string>().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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IOptions<WebOptions> web)
|
||||
public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, IConfiguration configuration)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(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<AuthenticationSchemeOptions>
|
|||
}
|
||||
|
||||
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<Claim> { 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<AuthenticationSchemeOptions>
|
|||
return Task.FromResult(AuthenticateResult.Fail("Ukendt API-token."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)));
|
||||
|
||||
/// <summary>A new random token and the line to put in appsettings.json.</summary>
|
||||
|
|
@ -55,3 +106,14 @@ public sealed class ApiTokenHandler(IOptionsMonitor<AuthenticationSchemeOptions>
|
|||
return (token, "sha256:" + Hash(token));
|
||||
}
|
||||
}
|
||||
|
||||
public static class ApiScopes
|
||||
{
|
||||
/// <summary>Reading needs the scope "read" for tokens (every token in the old format has it).</summary>
|
||||
public static TBuilder RequireRead<TBuilder>(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));
|
||||
}
|
||||
|
|
|
|||
524
src/SQLInsight.Web/Api/ApiV1Endpoints.cs
Normal file
524
src/SQLInsight.Web/Api/ApiV1Endpoints.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class ApiV1Endpoints
|
||||
{
|
||||
public const string Redacted = "[udeladt]";
|
||||
|
||||
private static readonly string[] ReportFormats = ["json", "pdf"];
|
||||
|
||||
/// <summary>Report types under /api/v1/reports/{type}.</summary>
|
||||
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<WebOptions> 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,
|
||||
};
|
||||
|
||||
/// <summary>SEC in SQLI-SEC-001, FT in COM-FT-002.</summary>
|
||||
public static string Category(string ruleId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ruleId);
|
||||
var parts = ruleId.Split('-');
|
||||
return parts.Length >= 3 ? parts[1] : ruleId;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SearchFindingsAsync(HttpContext http, InsightRepository repository, IOptions<WebOptions> 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<IResult> FindingAsync(long id, InsightRepository repository, IOptions<WebOptions> 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<IResult> 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<IResult> 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<IResult> ReportAsync(string type, InsightRepository repository, Display display, IOptions<WebOptions> 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<IResult> AnalysisAsync(string name, InsightRepository repository, IOptions<WebOptions> 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<IResult> TopSqlAsync(string name, InsightRepository repository, IOptions<WebOptions> 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<IResult> 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<IResult> ConfigurationAsync(string name, InsightRepository repository, IOptions<WebOptions> 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<InstanceConfigurationSnapshot>(id, "instance-config", ct).ConfigureAwait(false);
|
||||
var configuration = await repository.GetSnapshotAsync<ConfigurationSnapshot>(id, "configuration", ct).ConfigureAwait(false);
|
||||
var traceFlags = await repository.GetSnapshotAsync<TraceFlagSnapshot>(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<IResult> InstancePrivilegesAsync(string name, InsightRepository repository, IOptions<WebOptions> 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<IResult> AllPrivilegesAsync(InsightRepository repository, IOptions<WebOptions> 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<IResult> WaitTypeAsync(string name, string waitType, InsightRepository repository, IOptions<WebOptions> 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<WebOptions> 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;
|
||||
|
|
|
|||
|
|
@ -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<Display>();
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Negotiate" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ public sealed class WebOptions
|
|||
/// <summary>Time zone for display, e.g. Europe/Copenhagen. Defaults to the server's local time zone.</summary>
|
||||
public string? TimeZone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tokens for /api/ and /metrics (name → "sha256:<hex>"). Create one with <c>SQLInsight.Web new-api-token <name></c>.
|
||||
/// </summary>
|
||||
public IDictionary<string, string> ApiTokens { get; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>Include evidence marked sensitive (query text, logins, host names) in /api/v1/findings. Off by default.</summary>
|
||||
public bool ApiIncludesSensitiveEvidence { get; set; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The /api/v1 read endpoints for findings, rules, reports and the pages, with paging, redaction and OpenAPI.</summary>
|
||||
[Collection(SqlServerTestGroup.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
public class ApiV1Tests(SqlServerFixture sql) : IAsyncLifetime
|
||||
{
|
||||
private readonly string _database = "SQLInsightApiV1_" + Guid.NewGuid().ToString("N")[..8];
|
||||
private WebApplicationFactory<Program> _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<Program>().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<JsonElement> 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<Microsoft.AspNetCore.Routing.EndpointDataSource>().Endpoints
|
||||
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue