diff --git a/src/SQLInsight.Hub/HubOptions.cs b/src/SQLInsight.Hub/HubOptions.cs index 33f9a29..d0960ea 100644 --- a/src/SQLInsight.Hub/HubOptions.cs +++ b/src/SQLInsight.Hub/HubOptions.cs @@ -12,4 +12,7 @@ public sealed class HubServerOptions /// Most findings accepted per submission. public int MaxFindingsPerSubmission { get; set; } = 2000; + + /// Portal login attempts allowed per client IP per minute. + public int LoginAttemptsPerMinute { get; set; } = 10; } diff --git a/src/SQLInsight.Hub/Portal/Pages/Cases.razor b/src/SQLInsight.Hub/Portal/Pages/Cases.razor new file mode 100644 index 0000000..55837f8 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/Cases.razor @@ -0,0 +1,37 @@ +@page "/portal/sager" +@attribute [Authorize] +@inject PortalQueries Queries + +Supportsager · SQLInsight Hub +

Åbne supportsager

+
+ @if (_cases.Count == 0) + { +

Ingen åbne sager.

+ } + else + { + + + + @foreach (var c in _cases) + { + + + + + + + + + + } + +
SagKundeTitelFundAktiveOprettet
@c.Reference@c.Customer!.Name@c.Title@c.Findings.Count@c.Findings.Count(f => f.ResolvedAt == null)@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+ } +
+ +@code { + private IReadOnlyList _cases = []; + protected override async Task OnInitializedAsync() => _cases = await Queries.OpenCasesAsync(); +} diff --git a/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor new file mode 100644 index 0000000..3062f9d --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/CustomerDetail.razor @@ -0,0 +1,70 @@ +@page "/portal/kunder/{Id:int}" +@attribute [Authorize] +@inject PortalQueries Queries + +@if (_customer is null) +{ +

Ukendt kunde

+ return; +} + +@_customer.Name · SQLInsight Hub +

Kunder / @_customer.Name

+

@_customer.Name

+@{ var license = _customer.Licenses.Where(l => l.RevokedAt == null).OrderByDescending(l => l.ExpiresAt).FirstOrDefault(); } +

@(license is null ? "Ingen gyldig licens" : $"{license.Plan} til {license.ExpiresAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}") · @_customer.Instances.Count instanser

+ +
+

Instanser

+ + + + @foreach (var i in _customer.Instances.OrderBy(i => i.InstanceKey)) + { + + + + + + + } + +
InstansVersionAgentSeneste data
@i.InstanceKey
@i.ServerName
@i.Release
@i.ProductVersion @i.ProductLevel @i.ProductUpdateLevel · @i.Edition
@i.AgentVersion@(i.LastSubmissionAt?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) UTC
+
+ +
+

Aktive fund

+ +
+ +
+

Supportsager

+ @if (_customer.SupportCases.Count == 0) + { +

Ingen.

+ } + else + { + + + + @foreach (var c in _customer.SupportCases.OrderByDescending(c => c.CreatedAt)) + { + + } + +
SagTitelStatusOprettet
@c.Reference@c.Title@(c.IsOpen ? "Åben" : "Lukket")@c.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+ } +
+ +@code { + [Parameter] public int Id { get; set; } + private Customer? _customer; + private IReadOnlyList _findings = []; + + protected override async Task OnInitializedAsync() + { + _customer = await Queries.CustomerAsync(Id); + _findings = _customer is null ? [] : await Queries.FindingsAsync(Id); + } +} diff --git a/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor b/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor new file mode 100644 index 0000000..535d31f --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/FindingDetail.razor @@ -0,0 +1,68 @@ +@page "/portal/fund/{Id:long}" +@attribute [Authorize] +@inject PortalQueries Queries + +@if (_finding is null) +{ +

Ukendt fund

+ return; +} + +@{ var customer = _finding.Instance!.Customer!; } +@_finding.Title · SQLInsight Hub +

Kunder / @customer.Name / @_finding.Instance.InstanceKey / @_finding.RuleId

+

@_finding.Title

+

+ @(_finding.ResolvedAt is { } r ? "Løst " + r.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) + " UTC · " : "")Set første gang @_finding.FirstSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) · senest @_finding.LastSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC +

+ +

Forklaring

@_finding.Explanation

+ +
+

Målinger

+ + @foreach (var e in Evidence) + { + + } +
@e.Name@e.Value
+
+ +

Anbefaling til kunden

@_finding.Recommendation

+ +
+

Supportsag

+ @if (_finding.SupportCase is { } linked) + { +

Knyttet til @linked.Reference: @linked.Title (@(linked.IsOpen ? "åben" : "lukket"))

+
+ } + else + { + var open = customer.SupportCases.Where(c => c.IsOpen).ToList(); + @if (open.Count > 0) + { +
+ + + + + } +
+ + + + + + } +
+ +@code { + [Parameter] public long Id { get; set; } + private HubFinding? _finding; + + private IReadOnlyList Evidence => + System.Text.Json.JsonSerializer.Deserialize>(_finding!.EvidenceJson, System.Text.Json.JsonSerializerOptions.Web) ?? []; + + protected override async Task OnInitializedAsync() => _finding = await Queries.FindingAsync(Id); +} diff --git a/src/SQLInsight.Hub/Portal/Pages/Findings.razor b/src/SQLInsight.Hub/Portal/Pages/Findings.razor new file mode 100644 index 0000000..2d28379 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/Findings.razor @@ -0,0 +1,13 @@ +@page "/portal/fund" +@attribute [Authorize] +@inject PortalQueries Queries + +Fund · SQLInsight Hub +

Aktive fund på tværs af kunder

+

Middel og derover, sorteret efter alvor.

+
+ +@code { + private IReadOnlyList _rows = []; + protected override async Task OnInitializedAsync() => _rows = await Queries.FindingsAsync(minSeverity: 2); +} diff --git a/src/SQLInsight.Hub/Portal/Pages/Licenses.razor b/src/SQLInsight.Hub/Portal/Pages/Licenses.razor new file mode 100644 index 0000000..274d6d4 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/Licenses.razor @@ -0,0 +1,43 @@ +@page "/portal/licenser" +@attribute [Authorize] +@inject PortalQueries Queries + +Licenser · SQLInsight Hub +

Licenser

+

Licenser, som agenter har brugt. En spærret licens kan ikke længere sende data, selvom den ikke er udløbet.

+
+ + + + @foreach (var l in _licenses) + { + + + + + + + + + } + +
KundeAftaleUdløberMaks. instanserSenest brugtStatus
@l.Customer!.Name
@l.LicenseId
@l.Plan@l.ExpiresAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)@(l.MaxInstances == 0 ? "ubegrænset" : l.MaxInstances)@l.LastSeenAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC + @if (l.RevokedAt is { } revoked) + { + Spærret @revoked.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
@l.RevokedReason
+ } + else + { +
+ + + + + } +
+
+ +@code { + private IReadOnlyList _licenses = []; + protected override async Task OnInitializedAsync() => _licenses = await Queries.LicensesAsync(); +} diff --git a/src/SQLInsight.Hub/Portal/Pages/Login.razor b/src/SQLInsight.Hub/Portal/Pages/Login.razor new file mode 100644 index 0000000..1ded7b6 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/Login.razor @@ -0,0 +1,25 @@ +@page "/portal/login" +@attribute [AllowAnonymous] +@layout EmptyLayout + +Log ind · SQLInsight Hub + + + +@code { + [SupplyParameterFromQuery] public string? Fejl { get; set; } + [SupplyParameterFromQuery] public string? ReturnUrl { get; set; } +} diff --git a/src/SQLInsight.Hub/Portal/Pages/Overview.razor b/src/SQLInsight.Hub/Portal/Pages/Overview.razor new file mode 100644 index 0000000..7dd6c13 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Pages/Overview.razor @@ -0,0 +1,69 @@ +@page "/portal" +@attribute [Authorize] +@inject PortalQueries Queries + +Overblik · SQLInsight Hub + +

Kundeoverblik

+

Betalende kunders instanser og deres aktuelle problemer.

+ +
+
@_customers.Countkunder
+
@_customers.Sum(c => c.Instances)instanser
+
@_customers.Sum(c => c.Critical)kritiske fund
+
@_customers.Sum(c => c.High)høje fund
+
@_newnye fund, 7 dage
+
@_silentinstanser uden data i 1 time
+
+ +
+

Kunder

sorteret efter alvor
+ + + + @foreach (var c in _customers) + { + + + + + + + + + } + +
KundeAftaleLicens udløberInstanserFundSeneste data
@c.Name@(c.Plan ?? "–")@(c.LicenseExpires?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "–")@c.Instances + @if (c.Critical > 0) { } + @if (c.High > 0) { } + @if (c.Critical == 0 && c.High == 0) { ingen kritiske/høje } + @(c.LastSubmission?.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "aldrig") UTC
+
+ +
+

Mest udbredte problemer

hvor en vejledning eller en produktændring hjælper flest
+ + + + @foreach (var p in _common) + { + + } + +
RegelEksempelKunderFundVærst
@p.RuleId@p.Title@p.Customers@p.Findings
+
+ +@code { + private IReadOnlyList _customers = []; + private IReadOnlyList _common = []; + private int _new; + private int _silent; + + protected override async Task OnInitializedAsync() + { + _customers = await Queries.CustomersAsync(); + _common = await Queries.CommonProblemsAsync(); + _new = await Queries.NewFindingsSinceAsync(Queries.Now.AddDays(-7)); + _silent = await Queries.SilentInstancesAsync(); + } +} diff --git a/src/SQLInsight.Hub/Portal/PortalApp.razor b/src/SQLInsight.Hub/Portal/PortalApp.razor new file mode 100644 index 0000000..f968173 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/PortalApp.razor @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SQLInsight.Hub/Portal/PortalEndpoints.cs b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs new file mode 100644 index 0000000..5848872 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/PortalEndpoints.cs @@ -0,0 +1,121 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SQLInsight.Hub.Data; + +namespace SQLInsight.Hub.Portal; + +/// +/// Form posts from the portal (login and the few actions). Pages are server-rendered Razor components. +/// +public static class PortalEndpoints +{ + public const string LoginRateLimit = "portal-login"; + + public static void Map(IEndpointRouteBuilder app) + { + app.MapPost("/portal/login", LoginAsync).AllowAnonymous().RequireRateLimiting(LoginRateLimit); + app.MapPost("/portal/logout", async (HttpContext http) => + { + await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + return Results.Redirect("/portal/login"); + }); + + var portal = app.MapGroup("/portal/actions").RequireAuthorization(); + portal.MapPost("/findings/{id:long}/case", LinkCaseAsync); + portal.MapPost("/findings/{id:long}/unlink", async (long id, HubDbContext db, CancellationToken ct) => + { + var finding = await db.Findings.FindAsync([id], ct); + if (finding is null) + { + return Results.NotFound(); + } + + finding.SupportCaseId = null; + await db.SaveChangesAsync(ct); + return Results.Redirect($"/portal/fund/{id}"); + }); + portal.MapPost("/cases/{id:int}/close", async (int id, HubDbContext db, TimeProvider time, CancellationToken ct) => + { + var supportCase = await db.SupportCases.FindAsync([id], ct); + if (supportCase is null) + { + return Results.NotFound(); + } + + supportCase.IsOpen = false; + supportCase.ClosedAt = time.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(ct); + return Results.Redirect("/portal/sager"); + }); + portal.MapPost("/licenses/{id}/revoke", async (string id, [FromForm] string reason, HubDbContext db, TimeProvider time, CancellationToken ct) => + { + var license = await db.Licenses.FindAsync([id], ct); + if (license is null) + { + return Results.NotFound(); + } + + license.RevokedAt = time.GetUtcNow().UtcDateTime; + license.RevokedReason = string.IsNullOrWhiteSpace(reason) ? "Spærret" : reason.Trim(); + await db.SaveChangesAsync(ct); + return Results.Redirect("/portal/licenser"); + }); + } + + private static async Task LoginAsync(HttpContext http, StaffAccounts accounts, [FromForm] string userName, [FromForm] string password, [FromForm] string? returnUrl) + { + var user = await accounts.VerifyAsync(userName, password, http.RequestAborted); + if (user is null) + { + return Results.Redirect("/portal/login?fejl=1"); + } + + var identity = new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)), new Claim(ClaimTypes.Name, user.DisplayName), new Claim("username", user.UserName)], + CookieAuthenticationDefaults.AuthenticationScheme); + await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)); + + // Only local redirects: never an open redirect to another site. + return Results.LocalRedirect(returnUrl is { Length: > 0 } && returnUrl.StartsWith("/portal", StringComparison.Ordinal) && !returnUrl.StartsWith("//", StringComparison.Ordinal) ? returnUrl : "/portal"); + } + + private static async Task LinkCaseAsync(long id, [FromForm] int? caseId, [FromForm] string? reference, [FromForm] string? title, + HubDbContext db, TimeProvider time, CancellationToken ct) + { + var finding = await db.Findings.Include(f => f.Instance).SingleOrDefaultAsync(f => f.Id == id, ct); + if (finding is null) + { + return Results.NotFound(); + } + + if (caseId is { } existing) + { + var supportCase = await db.SupportCases.SingleOrDefaultAsync(c => c.Id == existing && c.CustomerId == finding.Instance!.CustomerId, ct); + if (supportCase is null) + { + return Results.BadRequest("Sagen tilhører en anden kunde."); + } + + finding.SupportCaseId = supportCase.Id; + } + else if (!string.IsNullOrWhiteSpace(reference)) + { + var supportCase = new SupportCase + { + CustomerId = finding.Instance!.CustomerId, + Reference = reference.Trim(), + Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(), + CreatedAt = time.GetUtcNow().UtcDateTime, + }; + db.SupportCases.Add(supportCase); + await db.SaveChangesAsync(ct); + finding.SupportCaseId = supportCase.Id; + } + + await db.SaveChangesAsync(ct); + return Results.Redirect($"/portal/fund/{id}"); + } +} diff --git a/src/SQLInsight.Hub/Portal/PortalQueries.cs b/src/SQLInsight.Hub/Portal/PortalQueries.cs new file mode 100644 index 0000000..38bfb61 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/PortalQueries.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using SQLInsight.Hub.Data; + +namespace SQLInsight.Hub.Portal; + +public sealed record CustomerRow(int Id, string Name, string? Plan, DateTime? LicenseExpires, int Instances, int Critical, int High, DateTime? LastSubmission); + +public sealed record CommonProblem(string RuleId, string Title, int Customers, int Findings, int WorstSeverity); + +public sealed record FindingRow(long Id, string Customer, int CustomerId, string Instance, string RuleId, string Subject, int Severity, string Title, DateTime FirstSeenAt, string? CaseReference); + +/// Read models for the portal pages. +public sealed class PortalQueries(HubDbContext db, TimeProvider time) +{ + public DateTime Now => time.GetUtcNow().UtcDateTime; + + public async Task> CustomersAsync(CancellationToken ct = default) + { + var customers = await db.Customers + .Select(c => new + { + c.Id, + c.Name, + License = c.Licenses.Where(l => l.RevokedAt == null).OrderByDescending(l => l.ExpiresAt).Select(l => new { l.Plan, l.ExpiresAt }).FirstOrDefault(), + Instances = c.Instances.Count, + Critical = c.Instances.SelectMany(i => i.Findings).Count(f => f.ResolvedAt == null && f.Severity == 4), + High = c.Instances.SelectMany(i => i.Findings).Count(f => f.ResolvedAt == null && f.Severity == 3), + LastSubmission = c.Instances.Max(i => i.LastSubmissionAt), + }) + .ToListAsync(ct).ConfigureAwait(false); + return customers + .Select(c => new CustomerRow(c.Id, c.Name, c.License?.Plan, c.License?.ExpiresAt, c.Instances, c.Critical, c.High, c.LastSubmission)) + .OrderByDescending(c => c.Critical).ThenByDescending(c => c.High).ThenBy(c => c.Name) + .ToList(); + } + + /// The problems affecting the most customers: where a knowledge base article or a product fix pays off. + public async Task> CommonProblemsAsync(int top = 10, CancellationToken ct = default) + { + var rows = await db.Findings.Where(f => f.ResolvedAt == null) + .GroupBy(f => f.RuleId) + .Select(g => new + { + RuleId = g.Key, + Title = g.OrderByDescending(f => f.Severity).Select(f => f.Title).First(), + Customers = g.Select(f => f.Instance!.CustomerId).Distinct().Count(), + Findings = g.Count(), + Worst = g.Max(f => f.Severity), + }) + .OrderByDescending(x => x.Customers).ThenByDescending(x => x.Worst).ThenByDescending(x => x.Findings) + .Take(top) + .ToListAsync(ct).ConfigureAwait(false); + return rows.Select(r => new CommonProblem(r.RuleId, r.Title, r.Customers, r.Findings, r.Worst)).ToList(); + } + + public Task NewFindingsSinceAsync(DateTime since, CancellationToken ct = default) => + db.Findings.CountAsync(f => f.FirstSeenAt >= since, ct); + + /// Instances that have not reported for an hour: agent stopped, network or license problem. + public Task SilentInstancesAsync(CancellationToken ct = default) + { + var limit = Now.AddHours(-1); + return db.Instances.CountAsync(i => i.LastSubmissionAt < limit, ct); + } + + public async Task> FindingsAsync(int? customerId = null, int minSeverity = 0, CancellationToken ct = default) => + await db.Findings + .Where(f => f.ResolvedAt == null && f.Severity >= minSeverity && (customerId == null || f.Instance!.CustomerId == customerId)) + .OrderByDescending(f => f.Severity).ThenBy(f => f.FirstSeenAt) + .Select(f => new FindingRow(f.Id, f.Instance!.Customer!.Name, f.Instance.CustomerId, f.Instance.InstanceKey, f.RuleId, f.Subject, f.Severity, f.Title, f.FirstSeenAt, + f.SupportCase == null ? null : f.SupportCase.Reference)) + .Take(500) + .ToListAsync(ct).ConfigureAwait(false); + + public Task CustomerAsync(int id, CancellationToken ct = default) => + db.Customers.Include(c => c.Instances).Include(c => c.Licenses).Include(c => c.SupportCases).AsSplitQuery().SingleOrDefaultAsync(c => c.Id == id, ct); + + public Task FindingAsync(long id, CancellationToken ct = default) => + db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer!).ThenInclude(c => c.SupportCases).Include(f => f.SupportCase) + .AsSplitQuery().SingleOrDefaultAsync(f => f.Id == id, ct); + + public async Task> OpenCasesAsync(CancellationToken ct = default) => + await db.SupportCases.Include(c => c.Customer).Include(c => c.Findings).Where(c => c.IsOpen) + .OrderBy(c => c.CreatedAt).AsSplitQuery().ToListAsync(ct).ConfigureAwait(false); + + public async Task> LicensesAsync(CancellationToken ct = default) => + await db.Licenses.Include(l => l.Customer).OrderBy(l => l.ExpiresAt).ToListAsync(ct).ConfigureAwait(false); +} diff --git a/src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor b/src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor new file mode 100644 index 0000000..4f3f76d --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Shared/EmptyLayout.razor @@ -0,0 +1,3 @@ +@inherits LayoutComponentBase + +@Body diff --git a/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor b/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor new file mode 100644 index 0000000..6abb14b --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Shared/FindingTable.razor @@ -0,0 +1,29 @@ +@if (Rows.Count == 0) +{ +

Ingen aktive fund.

+} +else +{ + + @if (ShowCustomer) { } + + @foreach (var f in Rows) + { + + + @if (ShowCustomer) { } + + + + + + + } + +
AlvorKundeInstansFundRegelSagSiden
@f.Customer@f.Instance@f.Title@f.RuleId@(f.CaseReference ?? "–")@f.FirstSeenAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
+} + +@code { + [Parameter, EditorRequired] public IReadOnlyList Rows { get; set; } = []; + [Parameter] public bool ShowCustomer { get; set; } = true; +} diff --git a/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor new file mode 100644 index 0000000..48b0f36 --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Shared/PortalLayout.razor @@ -0,0 +1,25 @@ +@inherits LayoutComponentBase + +
+ +
+
+ Logget ind som @context.User.Identity?.Name +
+
+
@Body
+
+
diff --git a/src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor b/src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor new file mode 100644 index 0000000..27f4c8a --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Shared/RedirectToLogin.razor @@ -0,0 +1,5 @@ +@inject NavigationManager Navigation +@code { + protected override void OnInitialized() => + Navigation.NavigateTo("/portal/login?returnUrl=" + Uri.EscapeDataString("/" + Navigation.ToBaseRelativePath(Navigation.Uri)), forceLoad: true); +} diff --git a/src/SQLInsight.Hub/Portal/Shared/Sev.razor b/src/SQLInsight.Hub/Portal/Shared/Sev.razor new file mode 100644 index 0000000..6469c9b --- /dev/null +++ b/src/SQLInsight.Hub/Portal/Shared/Sev.razor @@ -0,0 +1,9 @@ +@Name@(Count is { } c ? $" {c}" : "") + +@code { + [Parameter] public int Severity { get; set; } + [Parameter] public int? Count { get; set; } + + private string Name => Severity switch { 4 => "Kritisk", 3 => "Høj", 2 => "Middel", 1 => "Lav", _ => "Info" }; + private string Class => Severity switch { 4 => "sev-critical", 3 => "sev-high", 2 => "sev-medium", 1 => "sev-low", _ => "sev-info" }; +} diff --git a/src/SQLInsight.Hub/Portal/StaffAccounts.cs b/src/SQLInsight.Hub/Portal/StaffAccounts.cs new file mode 100644 index 0000000..74efeae --- /dev/null +++ b/src/SQLInsight.Hub/Portal/StaffAccounts.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SQLInsight.Hub.Data; + +namespace SQLInsight.Hub.Portal; + +/// +/// Vendor staff accounts for the portal. Passwords are hashed with ASP.NET Core Identity's PBKDF2 hasher. +/// +public sealed class StaffAccounts(HubDbContext db, TimeProvider time) +{ + private static readonly PasswordHasher Hasher = new(); + + public const int MinPasswordLength = 12; + + public async Task CreateAsync(string userName, string displayName, string password, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userName); + ArgumentNullException.ThrowIfNull(password); + if (password.Length < MinPasswordLength) + { + throw new ArgumentException($"Adgangskoden skal være mindst {MinPasswordLength} tegn.", nameof(password)); + } + + var user = new StaffUser { UserName = userName.Trim().ToLowerInvariant(), DisplayName = displayName, PasswordHash = string.Empty, CreatedAt = time.GetUtcNow().UtcDateTime }; + user.PasswordHash = Hasher.HashPassword(user, password); + db.StaffUsers.Add(user); + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return user; + } + + /// Returns the user if the password is right. Takes the same time whether or not the user exists. + public async Task VerifyAsync(string userName, string password, CancellationToken cancellationToken = default) + { + var normalized = (userName ?? string.Empty).Trim().ToLowerInvariant(); + var user = await db.StaffUsers.SingleOrDefaultAsync(u => u.UserName == normalized && u.IsActive, cancellationToken).ConfigureAwait(false); + var candidate = user ?? new StaffUser { UserName = normalized, DisplayName = string.Empty, PasswordHash = DummyHash }; + var result = Hasher.VerifyHashedPassword(candidate, candidate.PasswordHash, password ?? string.Empty); + if (user is null || result == PasswordVerificationResult.Failed) + { + return null; + } + + if (result == PasswordVerificationResult.SuccessRehashNeeded) + { + user.PasswordHash = Hasher.HashPassword(user, password!); + } + + user.LastLoginAt = time.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return user; + } + + private static readonly string DummyHash = Hasher.HashPassword(new StaffUser { UserName = "x", DisplayName = "x", PasswordHash = "x" }, Guid.NewGuid().ToString()); +} diff --git a/src/SQLInsight.Hub/Portal/_Imports.razor b/src/SQLInsight.Hub/Portal/_Imports.razor new file mode 100644 index 0000000..c96b9bd --- /dev/null +++ b/src/SQLInsight.Hub/Portal/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Globalization +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using SQLInsight.Hub.Data +@using SQLInsight.Hub.Portal +@using SQLInsight.Hub.Portal.Shared diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs index 670f182..0a67196 100644 --- a/src/SQLInsight.Hub/Program.cs +++ b/src/SQLInsight.Hub/Program.cs @@ -1,7 +1,10 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using SQLInsight.Hub; using SQLInsight.Hub.Api; using SQLInsight.Hub.Data; +using SQLInsight.Hub.Portal; namespace SQLInsight.Hub; @@ -12,6 +15,12 @@ public static class HubProgram { public static async Task Main(string[] args) { + if (args.FirstOrDefault() == "add-staff") + { + await AddStaffAsync(args).ConfigureAwait(false); + return; + } + var app = Build(args); await MigrateAsync(app.Services).ConfigureAwait(false); await app.RunAsync().ConfigureAwait(false); @@ -29,12 +38,69 @@ public static class HubProgram ?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL)."); builder.Services.AddDbContext(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddRazorComponents(); + builder.Services.AddCascadingAuthenticationState(); + builder.Services.AddSingleton(System.Text.Encodings.Web.HtmlEncoder.Create(System.Text.Unicode.UnicodeRanges.All)); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => + { + o.LoginPath = "/portal/login"; + o.Cookie.Name = "SQLInsightHub"; + o.Cookie.HttpOnly = true; + o.Cookie.SameSite = SameSiteMode.Strict; + o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; + o.ExpireTimeSpan = TimeSpan.FromHours(8); + o.SlidingExpiration = true; + }); + builder.Services.AddAuthorization(); + var loginAttempts = builder.Configuration.GetSection(HubServerOptions.SectionName).Get()?.LoginAttemptsPerMinute ?? 10; + builder.Services.AddRateLimiter(o => + { + o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + o.AddPolicy(PortalEndpoints.LoginRateLimit, http => RateLimitPartition.GetFixedWindowLimiter( + http.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions { PermitLimit = loginAttempts, Window = TimeSpan.FromMinutes(1) })); + }); + var app = builder.Build(); + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + app.Use(async (context, next) => + { + context.Response.Headers.XContentTypeOptions = "nosniff"; + context.Response.Headers.XFrameOptions = "DENY"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers.ContentSecurityPolicy = "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; form-action 'self'"; + await next(); + }); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseRateLimiter(); + app.UseAntiforgery(); + app.MapStaticAssets(); + app.MapGet("/", () => Results.Redirect("/portal")); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); FindingsEndpoint.Map(app); + PortalEndpoints.Map(app); + app.MapRazorComponents(); return app; } + /// SQLInsight.Hub add-staff <brugernavn> <navn>, adgangskode fra SQLINSIGHT_STAFF_PASSWORD eller stdin. + private static async Task AddStaffAsync(string[] args) + { + var app = Build(args.Skip(3).ToArray()); + await MigrateAsync(app.Services).ConfigureAwait(false); + var password = Environment.GetEnvironmentVariable("SQLINSIGHT_STAFF_PASSWORD") ?? Console.ReadLine() ?? string.Empty; + await using var scope = app.Services.CreateAsyncScope(); + var user = await scope.ServiceProvider.GetRequiredService().CreateAsync(args[1], args[2], password).ConfigureAwait(false); + Console.WriteLine($"Oprettet: {user.UserName}"); + } + public static async Task MigrateAsync(IServiceProvider services) { ArgumentNullException.ThrowIfNull(services); diff --git a/src/SQLInsight.Hub/wwwroot/app.css b/src/SQLInsight.Hub/wwwroot/app.css new file mode 100644 index 0000000..a80bbe1 --- /dev/null +++ b/src/SQLInsight.Hub/wwwroot/app.css @@ -0,0 +1,169 @@ +/* SQLInsight Pro design system: light first, neutral surfaces, one accent, semantic severity colours. */ +@font-face { + font-family: "Inter"; + src: url("fonts/inter-latin-wght-normal.woff2") format("woff2"); + font-weight: 100 900; font-style: normal; font-display: swap; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Inter"; + src: url("fonts/inter-latin-ext-wght-normal.woff2") format("woff2"); + font-weight: 100 900; font-style: normal; font-display: swap; + unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +:root { + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f1f3f6; + --border: #e4e7ec; + --text: #101828; + --text-2: #475467; + --text-3: #98a2b3; + --accent: #0e7490; /* deep teal */ + --accent-soft: #e0f2f6; + --accent-text: #0b5c72; + --critical: #d92d20; --critical-soft: #fee4e2; + --high: #e04f16; --high-soft: #ffead5; + --medium: #b54708; --medium-soft: #fef0c7; + --low: #1570ef; --low-soft: #d1e9ff; + --info: #667085; --info-soft: #f2f4f7; + --ok: #079455; --ok-soft: #dcfae6; + --radius: 12px; + --shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06); + --sidebar: 256px; + font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + font-feature-settings: "cv11", "ss01"; + color-scheme: light; +} + +:root[data-theme="dark"] { + --bg: #0c111d; --surface: #161b26; --surface-2: #1f2533; --border: #2a3140; + --text: #f5f5f6; --text-2: #cecfd2; --text-3: #85888e; + --accent: #22b8cf; --accent-soft: #0e2d36; --accent-text: #7dd9e8; + --critical-soft: #3b1512; --high-soft: #3a1d0d; --medium-soft: #3a2a07; --low-soft: #0f2744; --info-soft: #1f2533; --ok-soft: #0b2e1d; + --shadow: none; + color-scheme: dark; +} + +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.55; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +.icon { width: 18px; height: 18px; flex: none; } + +/* Shell */ +.shell { display: grid; grid-template-columns: var(--sidebar) 1fr; min-height: 100vh; background: linear-gradient(to right, var(--surface) calc(var(--sidebar) - 1px), var(--border) calc(var(--sidebar) - 1px), var(--border) var(--sidebar), transparent var(--sidebar)); } +.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; gap: 1.5rem; padding: 1.25rem 1rem; } +.brand { display: flex; align-items: center; gap: .6rem; color: var(--text); font-size: 1.05rem; padding: 0 .5rem; } +.brand:hover { text-decoration: none; } +.brand strong { color: var(--accent); } +.logo { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: linear-gradient(135deg, var(--accent), #155e75); color: #fff; font-weight: 700; font-size: .8rem; letter-spacing: .02em; } +.nav { display: flex; flex-direction: column; gap: 2px; } +.nav-label { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); padding: .9rem .75rem .35rem; } +.nav a { display: flex; align-items: center; gap: .65rem; padding: .5rem .75rem; border-radius: 8px; color: var(--text-2); font-weight: 500; } +.nav a:hover { background: var(--surface-2); text-decoration: none; color: var(--text); } +.nav a.active { background: var(--accent-soft); color: var(--accent-text); } +.support-card { margin-top: auto; padding: 1rem; border-radius: var(--radius); background: var(--surface-2); border: 1px solid var(--border); display: flex; flex-direction: column; gap: .35rem; } +.support-card .icon { color: var(--accent); width: 22px; height: 22px; } +.support-card p { margin: 0; color: var(--text-2); font-size: .82rem; } +.support-card a { font-weight: 600; font-size: .85rem; } +.main { display: flex; flex-direction: column; min-width: 0; } +.topbar { display: flex; justify-content: space-between; align-items: center; padding: .75rem 2rem; border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 85%, transparent); backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 5; } +.topbar-note { color: var(--text-3); font-size: .82rem; } +.theme-toggle { display: inline-flex; align-items: center; gap: .4rem; border: 1px solid var(--border); background: var(--surface); color: var(--text-2); border-radius: 8px; padding: .35rem .7rem; font: inherit; font-size: .82rem; cursor: pointer; } +.theme-toggle:hover { color: var(--text); background: var(--surface-2); } +.page { width: 100%; max-width: 1320px; padding: 1.75rem 2rem 2rem; } +.footer { color: var(--text-3); font-size: .8rem; padding: 1rem 2rem 2rem; } + +/* Typography */ +h1 { font-size: 1.6rem; line-height: 1.25; letter-spacing: -.02em; margin: .2rem 0 .35rem; display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +h2 { font-size: 1.02rem; letter-spacing: -.01em; margin: 0 0 .75rem; } +h3 { font-size: .95rem; margin: 1.1rem 0 .3rem; } +.lead { color: var(--text-2); margin: 0 0 1.5rem; } +.muted { color: var(--text-3); } +.crumbs { margin: 0; font-size: .85rem; color: var(--text-3); } +.mono, pre, code { font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; font-size: .82rem; } + +/* Cards and sections */ +.card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.25rem 1.4rem; margin-bottom: 1.25rem; } +.card-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .75rem; } +.card-head h2 { margin: 0; } +.card > table, .panel > table { margin: 0 -1.4rem -1.25rem; width: calc(100% + 2.8rem); border: 0; border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); box-shadow: none; } +.section { margin-top: 1.75rem; } +.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); gap: 1.25rem; } +.grid2 > .card, .grid2 > .panel { margin-bottom: 0; } + +/* KPI tiles */ +.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } +.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); padding: 1rem 1.2rem; display: flex; gap: .9rem; align-items: center; } +.kpi .kpi-icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; background: var(--c-soft, var(--accent-soft)); color: var(--c, var(--accent)); } +.kpi strong { display: block; font-size: 1.55rem; line-height: 1.1; letter-spacing: -.02em; } +.kpi span { color: var(--text-2); font-size: .82rem; } + +/* Tables */ +table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } +th, td { text-align: left; padding: .65rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; } +th { background: var(--surface-2); font-size: .74rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-2); font-weight: 600; } +tbody tr:hover td { background: color-mix(in srgb, var(--surface-2) 60%, transparent); } +tr:last-child td { border-bottom: 0; } +td .muted { font-size: .82rem; } +.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } +.bad { color: var(--critical); font-weight: 600; } +meter { width: 64px; height: 8px; margin-right: .4rem; vertical-align: middle; } + +/* Severity */ +.sev-critical { --c: var(--critical); --c-soft: var(--critical-soft); } +.sev-high { --c: var(--high); --c-soft: var(--high-soft); } +.sev-medium { --c: var(--medium); --c-soft: var(--medium-soft); } +.sev-low { --c: var(--low); --c-soft: var(--low-soft); } +.sev-info { --c: var(--info); --c-soft: var(--info-soft); } +.sev-ok { --c: var(--ok); --c-soft: var(--ok-soft); } +.badge { display: inline-flex; align-items: center; gap: .35rem; padding: .12rem .6rem; border-radius: 999px; font-size: .75rem; font-weight: 600; white-space: nowrap; color: var(--c, var(--info)); background: var(--c-soft, var(--info-soft)); } +.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.counts { display: flex; gap: .3rem; flex-wrap: wrap; } + +/* Charts */ +.chart { margin: 0; } +.chart svg { width: 100%; height: 90px; display: block; } +.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; vector-effect: non-scaling-stroke; } +.chart .grid { stroke: var(--border); stroke-width: 1; vector-effect: non-scaling-stroke; stroke-dasharray: 3 4; } +.chart figcaption { display: flex; justify-content: space-between; gap: 1rem; font-size: .82rem; color: var(--text-2); margin-top: .5rem; } +.chart figcaption span:first-child { font-weight: 600; color: var(--text); } + +/* Alerts */ +.alert { border: 1px solid var(--c, var(--critical)); background: var(--c-soft, var(--critical-soft)); border-radius: var(--radius); padding: .85rem 1rem; margin: 0 0 1.25rem; } +.alert.warn { --c: var(--medium); --c-soft: var(--medium-soft); } +.alert pre { white-space: pre-wrap; margin: .5rem 0 0; } + +/* Finding detail */ +.evidence { box-shadow: none; } +.evidence th { width: 38%; text-transform: none; letter-spacing: 0; font-size: .85rem; background: transparent; color: var(--text-2); font-weight: 500; } +.recommendation { border-left: 4px solid var(--ok); } +.script { position: relative; } +.script pre { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; overflow-x: auto; margin: .5rem 0 0; } +.copy { position: absolute; top: .5rem; right: .5rem; border: 1px solid var(--border); background: var(--surface); color: var(--text); border-radius: 6px; padding: .2rem .65rem; font: inherit; font-size: .8rem; cursor: pointer; } +.empty { background: var(--surface); border: 1px dashed var(--border); border-radius: var(--radius); padding: 1.25rem; color: var(--text-2); } + +@media (max-width: 900px) { + .shell { grid-template-columns: 1fr; } + .shell { background: none; } + .sidebar { position: static; height: auto; background: var(--surface); border-bottom: 1px solid var(--border); } + .support-card { display: none; } + .page, .topbar { padding-left: 1rem; padding-right: 1rem; } + .grid2 { grid-template-columns: 1fr; } +} + +/* Forms (portal) */ +.login { min-height: 100vh; display: grid; place-items: center; padding: 1rem; } +.login form { width: min(380px, 100%); display: flex; flex-direction: column; gap: .9rem; } +.login h1 { font-size: 1.3rem; } +label { display: flex; flex-direction: column; gap: .3rem; font-weight: 500; font-size: .9rem; } +input, select { font: inherit; padding: .5rem .65rem; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); } +.primary { font: inherit; font-weight: 600; padding: .55rem 1rem; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; } +.primary:hover { filter: brightness(1.08); } +.inline-form { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; } +.nowrap { white-space: nowrap; } +td .inline-form { flex-wrap: nowrap; margin: 0; } +td .inline-form input { width: 8rem; min-width: 0; } diff --git a/src/SQLInsight.Hub/wwwroot/favicon.svg b/src/SQLInsight.Hub/wwwroot/favicon.svg new file mode 100644 index 0000000..54199d9 --- /dev/null +++ b/src/SQLInsight.Hub/wwwroot/favicon.svg @@ -0,0 +1 @@ +SI diff --git a/src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt b/src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt new file mode 100644 index 0000000..40589da --- /dev/null +++ b/src/SQLInsight.Hub/wwwroot/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-ext-wght-normal.woff2 differ diff --git a/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2 b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/SQLInsight.Hub/wwwroot/fonts/inter-latin-wght-normal.woff2 differ diff --git a/src/SQLInsight.Hub/wwwroot/theme.js b/src/SQLInsight.Hub/wwwroot/theme.js new file mode 100644 index 0000000..eb022f8 --- /dev/null +++ b/src/SQLInsight.Hub/wwwroot/theme.js @@ -0,0 +1,3 @@ +try { + if (localStorage.getItem('sqlinsight-theme') === 'dark') document.documentElement.dataset.theme = 'dark'; +} catch { /* storage blocked: stay light */ } diff --git a/tests/SQLInsight.IntegrationTests/HubApiTests.cs b/tests/SQLInsight.IntegrationTests/HubApiTests.cs index 44804ab..afd1412 100644 --- a/tests/SQLInsight.IntegrationTests/HubApiTests.cs +++ b/tests/SQLInsight.IntegrationTests/HubApiTests.cs @@ -12,7 +12,7 @@ namespace SQLInsight.IntegrationTests; [Trait("Category", "Integration")] public class HubApiTests(HubFixture hub) : IClassFixture { - private static FindingsSubmission Submission(string instance, DateTimeOffset collectedAt, params (string Rule, string Subject, string Severity)[] findings) => new() + internal static FindingsSubmission Submission(string instance, DateTimeOffset collectedAt, params (string Rule, string Subject, string Severity)[] findings) => new() { SubmissionId = Guid.NewGuid(), AgentVersion = "1.0.0", diff --git a/tests/SQLInsight.IntegrationTests/HubFixture.cs b/tests/SQLInsight.IntegrationTests/HubFixture.cs index 20eb164..058d7c8 100644 --- a/tests/SQLInsight.IntegrationTests/HubFixture.cs +++ b/tests/SQLInsight.IntegrationTests/HubFixture.cs @@ -31,6 +31,7 @@ public sealed class HubFixture : IAsyncLifetime b.UseEnvironment("Development"); b.UseSetting("ConnectionStrings:Hub", ConnectionString); b.UseSetting($"SQLInsight:Hub:TrustedKeys:{KeyId}", PublicKey); + b.UseSetting("SQLInsight:Hub:LoginAttemptsPerMinute", "1000"); }); await HubProgram.MigrateAsync(Factory.Services); } diff --git a/tests/SQLInsight.IntegrationTests/HubPortalTests.cs b/tests/SQLInsight.IntegrationTests/HubPortalTests.cs new file mode 100644 index 0000000..a0fc858 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/HubPortalTests.cs @@ -0,0 +1,238 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SQLInsight.Hub; +using SQLInsight.Hub.Data; +using SQLInsight.Hub.Portal; + +namespace SQLInsight.IntegrationTests; + +[Trait("Category", "Integration")] +public partial class HubPortalTests(HubFixture hub) : IClassFixture +{ + private const string Password = "korrekt-hest-batteri"; + + [GeneratedRegex("name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"")] + private static partial Regex AntiforgeryField(); + + private static HttpClient Client(WebApplicationFactory factory) => + factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private async Task StaffAsync() + { + var userName = $"staff{Guid.NewGuid():N}"[..20]; + await using var scope = hub.Factory.Services.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().CreateAsync(userName, "Support Person", Password); + return userName; + } + + private static async Task TokenAsync(HttpClient client, string page) + { + var html = await client.GetStringAsync(page); + return WebUtility.HtmlDecode(AntiforgeryField().Match(html).Groups[1].Value); + } + + private static async Task PostFormAsync(HttpClient client, string page, string action, params (string Key, string Value)[] fields) + { + var form = fields.Select(f => new KeyValuePair(f.Key, f.Value)).Append(new("__RequestVerificationToken", await TokenAsync(client, page))); + using var content = new FormUrlEncodedContent(form); + return await client.PostAsync(action, content); + } + + private static Task LoginAsync(HttpClient client, string userName, string password, string returnUrl = "") => + PostFormAsync(client, "/portal/login", "/portal/login", ("userName", userName), ("password", password), ("returnUrl", returnUrl)); + + private async Task SignedInAsync() + { + var client = Client(hub.Factory); + var response = await LoginAsync(client, await StaffAsync(), Password); + Assert.Equal("/portal", response.Headers.Location?.OriginalString); + return client; + } + + private async Task SeedAsync(string customer, string license) + { + var client = hub.Factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") + { + Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-DB-007", "database: Sales", "Critical"))), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license); + (await client.SendAsync(request)).EnsureSuccessStatusCode(); + + await using var scope = hub.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Findings.Where(f => f.Instance!.Customer!.Name == customer).Select(f => f.Id).SingleAsync(); + } + + [Fact] + public async Task Pages_require_login() + { + var client = Client(hub.Factory); + + var response = await client.GetAsync("/portal/fund"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.StartsWith("/portal/login", response.Headers.Location!.OriginalString.Replace("http://localhost", "", StringComparison.Ordinal)); + var action = await client.PostAsync("/portal/actions/cases/1/close", null); + Assert.Equal(HttpStatusCode.Redirect, action.StatusCode); + Assert.Contains("/portal/login", action.Headers.Location!.OriginalString, StringComparison.Ordinal); + } + + [Fact] + public async Task Wrong_password_is_rejected() + { + var client = Client(hub.Factory); + var userName = await StaffAsync(); + + var wrong = await LoginAsync(client, userName, "forkert-adgangskode"); + var unknown = await LoginAsync(client, "findes-ikke", Password); + + Assert.Equal("/portal/login?fejl=1", wrong.Headers.Location?.OriginalString); + Assert.Equal("/portal/login?fejl=1", unknown.Headers.Location?.OriginalString); + Assert.Equal(HttpStatusCode.Redirect, (await client.GetAsync("/portal")).StatusCode); + } + + [Fact] + public async Task Login_without_antiforgery_token_is_refused() + { + var client = Client(hub.Factory); + using var content = new FormUrlEncodedContent([new("userName", await StaffAsync()), new("password", Password)]); + + var response = await client.PostAsync("/portal/login", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData("/portal/sager", "/portal/sager")] + [InlineData("https://evil.example/portal", "/portal")] + [InlineData("//evil.example/portal", "/portal")] + [InlineData("/\\evil.example", "/portal")] + public async Task Return_url_is_only_followed_inside_the_portal(string returnUrl, string expected) + { + var client = Client(hub.Factory); + + var response = await LoginAsync(client, await StaffAsync(), Password, returnUrl); + + Assert.Equal(expected, response.Headers.Location?.OriginalString); + } + + [Fact] + public async Task Login_is_rate_limited() + { + await using var isolated = hub.Factory.WithWebHostBuilder(b => b.UseSetting("SQLInsight:Hub:LoginAttemptsPerMinute", "10")); + var client = Client(isolated); + var statuses = new List(); + for (var i = 0; i < 12; i++) + { + statuses.Add((await LoginAsync(client, "findes-ikke", "forkert-adgangskode")).StatusCode); + } + + Assert.Equal(10, statuses.Count(s => s == HttpStatusCode.Redirect)); + Assert.Equal(HttpStatusCode.TooManyRequests, statuses[^1]); + } + + [Fact] + public async Task Signed_in_staff_sees_customers_findings_cases_and_licenses() + { + var customer = $"Portal {Guid.NewGuid():N}"[..20]; + var id = await SeedAsync(customer, hub.License(customer)); + var client = await SignedInAsync(); + + var overview = await client.GetStringAsync("/portal"); + var findings = await client.GetStringAsync("/portal/fund"); + var detail = await client.GetStringAsync($"/portal/fund/{id}"); + var licenses = await client.GetStringAsync("/portal/licenser"); + var cases = await client.GetAsync("/portal/sager"); + + Assert.Contains(customer, overview, StringComparison.Ordinal); + Assert.Contains("SQLI-DB-007", findings, StringComparison.Ordinal); + Assert.Contains("database: Sales", detail, StringComparison.Ordinal); + Assert.Contains(customer, licenses, StringComparison.Ordinal); + Assert.Equal(HttpStatusCode.OK, cases.StatusCode); + Assert.Contains("default-src 'self'", cases.Headers.GetValues("Content-Security-Policy").Single(), StringComparison.Ordinal); + } + + [Fact] + public async Task Finding_can_be_linked_to_a_new_support_case_and_the_case_closed() + { + var customer = $"Sag {Guid.NewGuid():N}"[..20]; + var id = await SeedAsync(customer, hub.License(customer)); + var client = await SignedInAsync(); + + var linked = await PostFormAsync(client, $"/portal/fund/{id}", $"/portal/actions/findings/{id}/case", ("reference", "SUP-1234"), ("title", "")); + Assert.Equal($"/portal/fund/{id}", linked.Headers.Location?.OriginalString); + Assert.Contains("SUP-1234", await client.GetStringAsync("/portal/sager"), StringComparison.Ordinal); + + int caseId; + await using (var scope = hub.Factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var finding = await db.Findings.Include(f => f.SupportCase).SingleAsync(f => f.Id == id); + caseId = finding.SupportCase!.Id; + Assert.Equal("SUP-1234", finding.SupportCase.Reference); + Assert.Equal(finding.Title, finding.SupportCase.Title); + } + + var closed = await PostFormAsync(client, "/portal/sager", $"/portal/actions/cases/{caseId}/close"); + Assert.Equal("/portal/sager", closed.Headers.Location?.OriginalString); + await using (var scope = hub.Factory.Services.CreateAsyncScope()) + { + Assert.False((await scope.ServiceProvider.GetRequiredService().SupportCases.SingleAsync(c => c.Id == caseId)).IsOpen); + } + } + + [Fact] + public async Task Finding_cannot_be_linked_to_another_customers_case() + { + var a = $"A {Guid.NewGuid():N}"[..20]; + var b = $"B {Guid.NewGuid():N}"[..20]; + var findingA = await SeedAsync(a, hub.License(a)); + var findingB = await SeedAsync(b, hub.License(b)); + var client = await SignedInAsync(); + await PostFormAsync(client, $"/portal/fund/{findingB}", $"/portal/actions/findings/{findingB}/case", ("reference", "SUP-B")); + int caseB; + await using (var scope = hub.Factory.Services.CreateAsyncScope()) + { + caseB = (await scope.ServiceProvider.GetRequiredService().Findings.SingleAsync(f => f.Id == findingB)).SupportCaseId!.Value; + } + + var response = await PostFormAsync(client, $"/portal/fund/{findingA}", $"/portal/actions/findings/{findingA}/case", ("caseId", caseB.ToString(System.Globalization.CultureInfo.InvariantCulture))); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Revoking_a_license_in_the_portal_blocks_the_agent() + { + var customer = $"Spær {Guid.NewGuid():N}"[..20]; + var licenseId = Guid.NewGuid().ToString("N"); + var license = hub.License(customer, id: licenseId); + await SeedAsync(customer, license); + var client = await SignedInAsync(); + + var revoked = await PostFormAsync(client, "/portal/licenser", $"/portal/actions/licenses/{licenseId}/revoke", ("reason", "Ikke betalt")); + + Assert.Equal("/portal/licenser", revoked.Headers.Location?.OriginalString); + var api = hub.Factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") + { + Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow)), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license); + Assert.Equal(HttpStatusCode.Forbidden, (await api.SendAsync(request)).StatusCode); + } + + [Fact] + public async Task Short_passwords_are_refused() + { + await using var scope = hub.Factory.Services.CreateAsyncScope(); + await Assert.ThrowsAsync(() => scope.ServiceProvider.GetRequiredService().CreateAsync("kort", "Kort", "123")); + } +}