Fase 3: data processing, hub retention and deployment
Some checks are pending
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions

Hub: nightly retention of resolved findings, the submission log and
silent instances; delete-customer erases a customer's data and blocks
its licenses; persistent data protection keys and trusted reverse
proxies. Docker image and compose file with PostgreSQL. The agent
refuses to send the license to a non-https hub. Documentation for the
hub, for data processing (GDPR basis for a data processing agreement)
and the README roadmap for phase 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
Claude 2026-09-29 09:29:20 +00:00
parent 3121b598f9
commit 19fbb02045
No known key found for this signature in database
13 changed files with 520 additions and 13 deletions

4
.dockerignore Normal file
View file

@ -0,0 +1,4 @@
**/bin/
**/obj/
.git/
*.private.pem

View file

@ -4,7 +4,7 @@
SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA. SQLInsight Pro er et overvågningsværktøj til Microsoft SQL Server, skrevet i C# på .NET. Det kører hos kunden og forlader ikke netværket, medmindre kunden selv vælger det. Værktøjet nøjes ikke med at vise tal. Det forklarer, hvad der er galt, hvorfor det er et problem, og hvordan det løses. Rådene har samme niveau som dem, man får fra en erfaren DBA.
> **Status:** Fase 1 og 2 er implementeret: agent, 61 regler (herunder Query Store, plananalyse, indexes, Always On og anomalier), notifikationer, lokalt repository og web-UI. Se [installation](docs/installation.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md). > **Status:** Fase 1–3 er implementeret: agent, 61 regler (herunder Query Store, plananalyse, indexes, Always On og anomalier), notifikationer, lokalt repository og web-UI. Dertil kommer licenser, signerede opdateringer og SQLInsight Hub med portal til betalende kunder. Se [installation](docs/installation.md), [opdateringer](docs/opdatering.md), [databehandling](docs/databehandling.md), [Roadmap](#roadmap) og [CONTRIBUTING.md](CONTRIBUTING.md).
--- ---
@ -131,9 +131,10 @@ Hvert **finding** indeholder:
| **SQLInsight.Agent** | .NET Worker Service | Indsamler data, kører regelmotoren og gemmer fund lokalt | | **SQLInsight.Agent** | .NET Worker Service | Indsamler data, kører regelmotoren og gemmer fund lokalt |
| **SQLInsight.Rules** | C#-bibliotek + versionerede regelpakker | Den samlede SQL Server-ekspertise som testbare regler | | **SQLInsight.Rules** | C#-bibliotek + versionerede regelpakker | Den samlede SQL Server-ekspertise som testbare regler |
| **SQLInsight.Web** | ASP.NET Core + Blazor | Dashboard, fund, historik, trends og administration | | **SQLInsight.Web** | ASP.NET Core + Blazor | Dashboard, fund, historik, trends og administration |
| **SQLInsight.Updater** | .NET | Tjekker for og installerer nye versioner af værktøj og regelpakker | | **SQLInsight.Updates** | .NET-bibliotek | Signeret opdateringsfeed: regelpakker, programpakker, verifikation og installation med tilbagerulning (`SQLInsight.Agent update …`) |
| **SQLInsight.Hub** | ASP.NET Core + EF Core (Npgsql) | Centralt API, der modtager fund fra betalende kunder og skriver dem til PostgreSQL | | **SQLInsight.Licensing** | .NET-bibliotek | Signerede licenser og manifester (ECDSA P-256), verificeret offline |
| **SQLInsight.Hub.Portal** | ASP.NET Core + Blazor | Internt overblik: hvilke kunder har hvilke problemer, og hvor alvorlige er de? | | **SQLInsight.Hub** | ASP.NET Core + EF Core (Npgsql) + Blazor | Centralt API, der modtager fund fra betalende kunder og skriver dem til PostgreSQL, og den interne portal: hvilke kunder har hvilke problemer, og hvor alvorlige er de? |
| **SQLInsight.LicenseTool** | .NET-konsol (kun hos SEOSoft) | Nøglepar, licenser, regelpakker og udgivelse af opdateringsfeedet |
### Designprincipper ### Designprincipper
@ -192,13 +193,14 @@ Hvert **finding** indeholder:
- [x] Linux-agent (systemd) og Azure SQL Managed Instance (evergreen, Entra ID-login, Azure-håndterede områder vises som sådan). MI er ikke testet mod en rigtig instans - [x] Linux-agent (systemd) og Azure SQL Managed Instance (evergreen, Entra ID-login, Azure-håndterede områder vises som sådan). MI er ikke testet mod en rigtig instans
### Fase 3: Kommerciel platform ### Fase 3: Kommerciel platform
- [ ] Licensmodel: signerede licensnøgler, der kan verificeres offline - [x] Licensmodel: signerede licensnøgler (ECDSA P-256), der verificeres offline, og et værktøj til at udstede dem
- [ ] Auto-update af værktøj og regelpakker med stable- og preview-kanal - [x] Auto-update af regelpakker (uden genstart) og af værktøjet, med stable- og preview-kanal. Feedet er signeret og kan ligge på https eller et fildrev (offline). Installationen er privilegeret, med backup, sundhedstjek og tilbagerulning. Se [docs/opdatering.md](docs/opdatering.md). Afprøvet på Linux; Windows-stien (tjenester, IIS app_offline, planlagt opgave) er ikke kørt på Windows endnu
- [ ] Findings-sync fra Agent til Hub API med kø og retry ved netværksfejl - [x] Findings-sync fra Agent til Hub API med kø, retry og backoff. Følsomme målinger udelades som standard, og kunden kan se præcis det sendte
- [ ] SQLInsight Hub med PostgreSQL: modtagelse, deduplikering og historik pr. kunde - [x] SQLInsight Hub med PostgreSQL: modtagelse, deduplikering (idempotent), historik pr. kunde, spærring af licenser og Docker Compose. Se [docs/hub.md](docs/hub.md)
- [ ] Hub-portal: overblik over kunder, de mest kritiske fund og tendenser på tværs af kunder - [x] Hub-portal: overblik over kunder, kritiske fund og de mest udbredte problemer på tværs af kunder. Grafer over tendenser over tid mangler endnu
- [ ] Integration med support-flowet: fund kan knyttes til en supportsag - [x] Integration med support-flowet: fund kan knyttes til en supportsag (sagsnummer fra supportsystemet). Der er endnu ingen direkte kobling til et bestemt supportsystem
- [ ] Dokumentation til databehandleraftale (GDPR) - [x] Dokumentation til databehandleraftale (GDPR), opbevaringsfrister og sletning af kunder i hubben. Se [docs/databehandling.md](docs/databehandling.md)
- [ ] Log over medarbejderes adgang og tofaktorlogin i portalen
### Fase 4: Videre ### Fase 4: Videre
- [ ] Anbefalinger på tværs af kunder: "denne CU har løst problemet hos andre" - [ ] Anbefalinger på tværs af kunder: "denne CU har løst problemet hos andre"

18
deploy/hub/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
# SQLInsight Hub. Build from the repository root:
# docker build -f deploy/hub/Dockerfile -t sqlinsight-hub .
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/SQLInsight.Hub -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
# Keys for login cookies, and the update feed, live on volumes.
ENV ASPNETCORE_HTTP_PORTS=8080 \
SQLInsight__Hub__DataProtectionKeysPath=/data/keys
RUN mkdir -p /data/keys /data/feed && chown -R $APP_UID /data
VOLUME ["/data"]
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "SQLInsight.Hub.dll"]

View file

@ -0,0 +1,44 @@
# SQLInsight Hub with PostgreSQL. Put a TLS-terminating reverse proxy (IIS/ARR, nginx, Caddy, Traefik) in front:
# agents and staff must reach the hub over https.
#
# HUB_DB_PASSWORD=... docker compose up -d
# docker compose exec hub dotnet SQLInsight.Hub.dll add-staff <brugernavn> "<navn>"
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: sqlinsight_hub
POSTGRES_USER: sqlinsight_hub
POSTGRES_PASSWORD: ${HUB_DB_PASSWORD:?set HUB_DB_PASSWORD}
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sqlinsight_hub -d sqlinsight_hub"]
interval: 10s
retries: 5
hub:
build:
context: ../..
dockerfile: deploy/hub/Dockerfile
image: sqlinsight-hub
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
ConnectionStrings__Hub: "Host=postgres;Database=sqlinsight_hub;Username=sqlinsight_hub;Password=${HUB_DB_PASSWORD};GSS Encryption Mode=Disable"
# The public key licenses are signed with (the vendor tool's keygen prints it):
# SQLInsight__Hub__TrustedKeys__k20260929: "MFkw..."
SQLInsight__Hub__UpdateFeedPath: /data/feed
# Address of the reverse proxy, so login rate limiting sees real client addresses:
# SQLInsight__Hub__KnownProxies__0: "172.18.0.1"
volumes:
- hubdata:/data
ports:
- "127.0.0.1:8080:8080"
volumes:
postgres:
hubdata:

87
docs/databehandling.md Normal file
View file

@ -0,0 +1,87 @@
# Databehandling og GDPR
Dette dokument beskriver, hvilke data SQLInsight Pro behandler, hvor de ligger, og hvem der har adgang. Det er grundlag for en databehandleraftale og for kundens egen risikovurdering. **Det er ikke juridisk rådgivning.** Afsnit markeret *[udfyldes af SEOSoft]* skal udfyldes, før dokumentet bruges over for kunder.
## Kort fortalt
| Udgave | Forlader data kundens netværk? | Rolle |
|---|---|---|
| Community (gratis) | Nej. Agent, repository og webbrugerflade kører hos kunden. | SEOSoft behandler ingen data. |
| Professional / Enterprise med deling til SQLInsight Hub | Ja, fund pr. instans sendes til SEOSofts hub. Slået fra som standard. | Kunden er dataansvarlig, SEOSoft er databehandler (GDPR art. 28). |
Opdateringstjek sender ingen data om kunden. Agenten henter et signeret manifest og filer fra feedet, og feed-serveren ser kun den IP-adresse, der henter. Med et lokalt feed (mappe eller fildrev) kontaktes SEOSoft slet ikke.
## Hvad sendes til SQLInsight Hub?
Kun når `SQLInsight:Hub:Enabled` er `true` og der er en gyldig licens. Agenten sender hver instans' samlede tilstand (højst hvert 15. minut, og straks ved ændringer):
| Data | Eksempel | Personoplysning? |
|---|---|---|
| Instansens navn, som kunden har valgt i konfigurationen | `prod-sql01` | Normalt nej |
| SQL Server-version, CU-niveau og edition | `15.0.4385.2`, `Enterprise` | Nej |
| Serverens netværksnavn | Kun hvis `SendServerName` er slået til | Normalt nej |
| Hvilke regler der er kørt, og deres resultat | `SQLI-CFG-002: Findings` | Nej |
| Fund: regel, alvor, titel, forklaring, anbefaling, emne | `database: Sales`, `job: Nightly ETL` | Normalt nej. Database- og jobnavne kan i sjældne tilfælde indeholde navne. |
| Målinger (evidens) | `MAXDOP: 0`, `Længste ventetid: 412 s` | Nej |
| **Følsomme målinger**: forespørgselstekst, logins, værts- og programnavne, fejlbeskeder fra jobs | `DOMÆNE\jens` | **Ja, kan være.** Sendes som `[udeladt]`, medmindre kunden slår `SendSensitiveEvidence` til. |
Der sendes aldrig tabeldata, forespørgselsresultater, adgangskoder eller forbindelsesstrenge.
Kunden kan se præcis det, der er sendt, under **System** i webbrugerfladen ("Det sendes til SQLInsight Hub").
Slår kunden `SendSensitiveEvidence` til, kan fund indeholde personoplysninger (brugernavne, og data i forespørgselstekst som fx `WHERE cpr = '…'`). Det bør kun ske efter aftale, og det skal fremgå af databehandleraftalen.
## Formål
Data i hubben bruges kun til:
- at yde den support, kunden har købt (Professional: prioriteret mail, Enterprise: telefon)
- at gøre SEOSofts support opmærksom på kritiske fund hos kunden
- at se, hvilke problemer der går igen på tværs af kunder, så regler, vejledninger og produkt kan forbedres (aggregeret)
Data sælges ikke og videregives ikke.
## Opbevaring
| Data | Slettes |
|---|---|
| Aktive fund | Når de er løst, og derefter efter 365 dage (medmindre de hører til en åben supportsag) |
| Log over modtagelser (tidspunkt, antal fund; ikke indholdet) | 90 dage |
| Instanser, der ikke har rapporteret | 180 dage, med deres fund |
| Alt om en kunde | Når aftalen ophører, eller kunden beder om det (`delete-customer`) |
Fristerne kan ændres i hubbens konfiguration (se [hub.md](hub.md)). I kundens eget repository styres opbevaringen af `SQLInsight:Retention`.
## Hvor ligger data?
- Hubben og PostgreSQL-databasen: *[udfyldes af SEOSoft: land, datacenter/hostingleverandør]*
- Backup: *[udfyldes af SEOSoft: hvor, hvor længe, krypteret]*
- Underdatabehandlere: *[udfyldes af SEOSoft: fx hostingleverandør. Ingen, hvis hubben kører på SEOSofts egne servere]*
- Overførsel til tredjelande: *[udfyldes af SEOSoft: forventet ingen]*
## Sikkerhed (tekniske og organisatoriske foranstaltninger)
Indbygget i produktet:
- **Kryptering under transport.** Agenten sender kun til https-adresser (loopback undtaget), så licensen aldrig sendes i klartekst.
- **Autentifikation.** Hver kunde har en signeret licens (ECDSA P-256), der verificeres ved hver modtagelse. Spærrede og udløbne licenser afvises.
- **Dataminimering.** Følsomme målinger udelades som standard, og serverens navn sendes ikke som standard.
- **Adskillelse.** Data knyttes til kunden ud fra den signerede licens, ikke ud fra noget agenten selv angiver.
- **Portalen.** Kun navngivne SEOSoft-medarbejdere har adgang. Adgangskoder er PBKDF2-hashede og mindst 12 tegn, login er begrænset til 10 forsøg i minuttet pr. IP, og cookies er HttpOnly, SameSite=Strict og Secure. Der er CSRF-beskyttelse og en streng Content-Security-Policy.
- **Opbevaring og sletning**, som beskrevet ovenfor.
- **Gennemsigtighed.** Kunden kan se det sendte indhold i sin egen webbrugerflade.
Mangler (planlagt):
- Log over, hvilke medarbejdere der har set hvilke kunders data
- Tofaktorlogin til portalen (indtil da: kør portalen bag VPN eller en reverse proxy med SSO)
Hos SEOSoft (*[udfyldes af SEOSoft]*): kryptering af disk og backup, adgangsstyring til serveren, patching, fortrolighedserklæring for medarbejdere, procedure ved brud på persondatasikkerheden (underretning af kunden uden unødig forsinkelse), og hvordan databehandleraftalen følges op.
## Hvis kunden ikke ønsker at dele data
Deling er slået fra som standard og er ikke en betingelse for Professional eller Enterprise. Uden deling kan supporten stadig hjælpe ud fra skærmbilleder eller udtræk, som kunden selv sender. Så gælder kundens egne regler for, hvad der deles.
## Kundens egen installation
Agent og webbrugerflade gemmer data i kundens eget repository hos kunden. Her er kunden selv ansvarlig. Repositoriet indeholder de samme typer data som ovenfor, inklusive følsomme målinger (forespørgselstekst, logins, deadlock-grafer), fordi de er nødvendige for at løse problemerne lokalt. Adgang til webbrugerfladen styres med Windows-login, og databasebrugeren har kun læserettigheder.

83
docs/hub.md Normal file
View file

@ -0,0 +1,83 @@
# SQLInsight Hub (for SEOSoft)
SQLInsight Hub modtager fund fra betalende kunders agenter og samler dem i PostgreSQL. Portalen giver supporten overblik over kunder, kritiske fund, de mest udbredte problemer på tværs af kunder, supportsager og licenser. Hubben kan også servere opdateringsfeedet.
Kunder installerer ikke hubben. Den kører hos SEOSoft.
## Krav
- PostgreSQL 14 eller nyere (testet med 17)
- .NET 10 runtime (ASP.NET Core), eller Docker
- En reverse proxy med TLS foran. Agenter sender kun til https-adresser, fordi licensen er deres adgangsnøgle.
## Med Docker Compose
```bash
cd deploy/hub
export HUB_DB_PASSWORD='<langt tilfældigt kodeord>'
docker compose up -d
docker compose exec hub dotnet SQLInsight.Hub.dll add-staff <brugernavn> "<fulde navn>" # adgangskode på stdin, mindst 12 tegn
```
Ret `docker-compose.yml`:
- `SQLInsight__Hub__TrustedKeys__<kid>`: den offentlige nøgle, licenserne er signeret med (fra `keygen`).
- `SQLInsight__Hub__KnownProxies__0`: reverse proxyens adresse, så login-begrænsningen ser klientens rigtige IP.
Volumen `hubdata` indeholder nøglerne til login-cookies (`/data/keys`) og opdateringsfeedet (`/data/feed`). Tag backup af den og af PostgreSQL.
## Uden Docker
```bash
dotnet publish src/SQLInsight.Hub -c Release -o /opt/sqlinsight/hub
```
`appsettings.json`:
```json
{
"ConnectionStrings": { "Hub": "Host=db01;Database=sqlinsight_hub;Username=sqlinsight_hub;Password=…" },
"SQLInsight": {
"Hub": {
"TrustedKeys": { "k20260929": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…" },
"DataProtectionKeysPath": "/var/lib/sqlinsight-hub/keys",
"UpdateFeedPath": "/var/lib/sqlinsight-hub/feed",
"KnownProxies": [ "10.0.0.5" ],
"Retention": { "ResolvedFindings": "365.00:00:00", "Submissions": "90.00:00:00", "SilentInstances": "180.00:00:00" }
}
}
}
```
Hubben kan køre som Windows-tjeneste eller systemd-enhed (`Type=notify`) på samme måde som agenten. Databaseskemaet oprettes og opgraderes ved start.
## Licenser
```bash
# Én gang: nøglepar. Den private nøgle forlader aldrig SEOSofts maskine og må aldrig i git.
dotnet run --project tools/SQLInsight.LicenseTool -- keygen --out ~/sqlinsight-keys
# Pr. kunde
dotnet run --project tools/SQLInsight.LicenseTool -- issue --key ~/sqlinsight-keys/sqlinsight-k20260929.private.pem \
--kid k20260929 --customer "Kunde A/S" --plan Professional --expires 2027-09-30 --instances 10
```
Den offentlige nøgle lægges i `src/SQLInsight.Licensing/trusted-keys.json` før en release, så agenterne kan verificere licensen offline. Hubben får den via `TrustedKeys`. Licenser kan spærres i portalen under **Licenser**. Agenten får så 403 ved næste afsendelse.
## Opbevaring og sletning
Hver nat sletter hubben:
| Data | Efter |
|---|---|
| Løste fund (medmindre de hører til en åben supportsag) | `Retention:ResolvedFindings`, 365 dage |
| Log over modtagelser | `Retention:Submissions`, 90 dage |
| Instanser, der ikke har rapporteret, med deres fund | `Retention:SilentInstances`, 180 dage |
Når en aftale ophører, eller en kunde beder om sletning:
```bash
dotnet SQLInsight.Hub.dll delete-customer "Kunde A/S" --confirm
```
Alle kundens instanser, fund, regelstatus, modtagelser og supportsager slettes. Licenserne spærres, så agenterne ikke kan sende igen, og kunden omdøbes til "Slettet kunde #id". Se også [databehandling.md](databehandling.md).

View file

@ -62,6 +62,13 @@ public sealed partial class HubSyncService(
return 0; return 0;
} }
// The license is the credential: never send it in clear text.
if (settings.Url.Scheme != Uri.UriSchemeHttps && !settings.Url.IsLoopback)
{
await repository.SetHubErrorAsync($"Hub-adressen {settings.Url} skal bruge https. Intet er sendt.", time.GetUtcNow(), cancellationToken).ConfigureAwait(false);
return 0;
}
var sent = 0; var sent = 0;
using var http = httpClientFactory.CreateClient("hub"); using var http = httpClientFactory.CreateClient("hub");
foreach (var item in await repository.GetDueSubmissionsAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false)) foreach (var item in await repository.GetDueSubmissionsAsync(time.GetUtcNow(), cancellationToken: cancellationToken).ConfigureAwait(false))

View file

@ -21,4 +21,31 @@ public sealed class HubServerOptions
/// Empty: the hub does not serve updates. /// Empty: the hub does not serve updates.
/// </summary> /// </summary>
public string? UpdateFeedPath { get; set; } public string? UpdateFeedPath { get; set; }
/// <summary>
/// Folder for the keys that protect login cookies and form tokens. Set it (and keep it) when running in a container
/// or on more than one server; otherwise staff are logged out on every restart.
/// </summary>
public string? DataProtectionKeysPath { get; set; }
/// <summary>
/// IP addresses of reverse proxies in front of the hub. Their X-Forwarded-For/-Proto headers are trusted, so login
/// rate limiting sees the real client address.
/// </summary>
public IList<string> KnownProxies { get; } = [];
public RetentionOptions Retention { get; set; } = new();
}
/// <summary>How long the hub keeps data it no longer needs. Applied once a day.</summary>
public sealed class RetentionOptions
{
/// <summary>Resolved findings are deleted this long after they were resolved (unless linked to an open support case).</summary>
public TimeSpan ResolvedFindings { get; set; } = TimeSpan.FromDays(365);
/// <summary>The submission log (when what was received) is deleted after this.</summary>
public TimeSpan Submissions { get; set; } = TimeSpan.FromDays(90);
/// <summary>Instances that have not reported for this long are deleted with their findings.</summary>
public TimeSpan SilentInstances { get; set; } = TimeSpan.FromDays(180);
} }

View file

@ -0,0 +1,92 @@
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SQLInsight.Hub.Data;
namespace SQLInsight.Hub.Maintenance;
public sealed record RetentionResult(int Findings, int Submissions, int Instances);
/// <summary>Data minimisation: retention of old data, and erasure of a customer when the agreement ends.</summary>
public sealed class HubMaintenance(HubDbContext db, IOptions<HubServerOptions> options, TimeProvider time)
{
public async Task<RetentionResult> ApplyRetentionAsync(CancellationToken cancellationToken = default)
{
var retention = options.Value.Retention;
var now = time.GetUtcNow().UtcDateTime;
var silentBefore = now - retention.SilentInstances;
var silent = db.Instances.Where(i => i.LastSubmissionAt != null && i.LastSubmissionAt < silentBefore).Select(i => i.Id);
await db.RuleStates.Where(r => silent.Contains(r.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Submissions.Where(s => silent.Contains(s.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Findings.Where(f => silent.Contains(f.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
var instances = await db.Instances.Where(i => i.LastSubmissionAt != null && i.LastSubmissionAt < silentBefore).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
var resolvedBefore = now - retention.ResolvedFindings;
var findings = await db.Findings
.Where(f => f.ResolvedAt != null && f.ResolvedAt < resolvedBefore && (f.SupportCase == null || !f.SupportCase.IsOpen))
.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
var submittedBefore = now - retention.Submissions;
var submissions = await db.Submissions.Where(s => s.ReceivedAt < submittedBefore).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
return new RetentionResult(findings, submissions, instances);
}
/// <summary>
/// Deletes everything received from a customer and blocks its licenses. What remains is the license ids with the
/// revocation (so the licenses cannot sync again) under an anonymous customer row.
/// </summary>
public async Task<bool> DeleteCustomerAsync(string name, CancellationToken cancellationToken = default)
{
var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == name, cancellationToken).ConfigureAwait(false);
if (customer is null)
{
return false;
}
var now = time.GetUtcNow().UtcDateTime;
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
var instances = db.Instances.Where(i => i.CustomerId == customer.Id).Select(i => i.Id);
await db.RuleStates.Where(r => instances.Contains(r.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Submissions.Where(s => instances.Contains(s.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Findings.Where(f => instances.Contains(f.InstanceId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Instances.Where(i => i.CustomerId == customer.Id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.SupportCases.Where(c => c.CustomerId == customer.Id).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await db.Licenses.Where(l => l.CustomerId == customer.Id && l.RevokedAt == null)
.ExecuteUpdateAsync(l => l.SetProperty(x => x.RevokedAt, now).SetProperty(x => x.RevokedReason, "Kunden er slettet"), cancellationToken).ConfigureAwait(false);
await db.Customers.Where(c => c.Id == customer.Id)
.ExecuteUpdateAsync(c => c.SetProperty(x => x.Name, "Slettet kunde #" + customer.Id.ToString(CultureInfo.InvariantCulture)), cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return true;
}
}
/// <summary>Applies the retention rules once a day.</summary>
public sealed partial class HubRetentionService(IServiceScopeFactory scopes, TimeProvider time, ILogger<HubRetentionService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromDays(1), time);
do
{
try
{
await using var scope = scopes.CreateAsyncScope();
var result = await scope.ServiceProvider.GetRequiredService<HubMaintenance>().ApplyRetentionAsync(stoppingToken).ConfigureAwait(false);
LogRetention(logger, result.Findings, result.Submissions, result.Instances);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogRetentionFailed(logger, ex);
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
[LoggerMessage(Level = LogLevel.Information, Message = "Opbevaring: slettede {Findings} løste fund, {Submissions} modtagelser og {Instances} tavse instanser.")]
private static partial void LogRetention(ILogger logger, int findings, int submissions, int instances);
[LoggerMessage(Level = LogLevel.Error, Message = "Oprydning efter opbevaringsreglerne fejlede.")]
private static partial void LogRetentionFailed(ILogger logger, Exception exception);
}

View file

@ -1,9 +1,11 @@
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SQLInsight.Hub; using SQLInsight.Hub;
using SQLInsight.Hub.Api; using SQLInsight.Hub.Api;
using SQLInsight.Hub.Data; using SQLInsight.Hub.Data;
using SQLInsight.Hub.Maintenance;
using SQLInsight.Hub.Portal; using SQLInsight.Hub.Portal;
namespace SQLInsight.Hub; namespace SQLInsight.Hub;
@ -21,6 +23,12 @@ public static class HubProgram
return; return;
} }
if (args.FirstOrDefault() == "delete-customer")
{
await DeleteCustomerAsync(args).ConfigureAwait(false);
return;
}
var app = Build(args); var app = Build(args);
await MigrateAsync(app.Services).ConfigureAwait(false); await MigrateAsync(app.Services).ConfigureAwait(false);
await app.RunAsync().ConfigureAwait(false); await app.RunAsync().ConfigureAwait(false);
@ -38,6 +46,22 @@ public static class HubProgram
?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL)."); ?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL).");
builder.Services.AddDbContext<HubDbContext>(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention()); builder.Services.AddDbContext<HubDbContext>(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
var settings = builder.Configuration.GetSection(HubServerOptions.SectionName).Get<HubServerOptions>() ?? new HubServerOptions();
if (settings.DataProtectionKeysPath is { Length: > 0 } keys)
{
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(keys)).SetApplicationName("SQLInsight.Hub");
}
builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
foreach (var proxy in settings.KnownProxies)
{
o.KnownProxies.Add(System.Net.IPAddress.Parse(proxy));
}
});
builder.Services.AddScoped<HubMaintenance>();
builder.Services.AddHostedService<HubRetentionService>();
builder.Services.AddScoped<StaffAccounts>(); builder.Services.AddScoped<StaffAccounts>();
builder.Services.AddScoped<PortalQueries>(); builder.Services.AddScoped<PortalQueries>();
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
@ -54,7 +78,7 @@ public static class HubProgram
o.SlidingExpiration = true; o.SlidingExpiration = true;
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
var loginAttempts = builder.Configuration.GetSection(HubServerOptions.SectionName).Get<HubServerOptions>()?.LoginAttemptsPerMinute ?? 10; var loginAttempts = settings.LoginAttemptsPerMinute;
builder.Services.AddRateLimiter(o => builder.Services.AddRateLimiter(o =>
{ {
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
@ -64,6 +88,7 @@ public static class HubProgram
}); });
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseHsts(); app.UseHsts();
@ -82,7 +107,7 @@ public static class HubProgram
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
if (app.Configuration.GetSection(HubServerOptions.SectionName).Get<HubServerOptions>()?.UpdateFeedPath is { Length: > 0 } feed) if (settings.UpdateFeedPath is { Length: > 0 } feed)
{ {
// Everything in the feed is signed or pinned by hash in a signed manifest; the hub only has to serve it. // Everything in the feed is signed or pinned by hash in a signed manifest; the hub only has to serve it.
var types = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider(); var types = new Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider();
@ -115,6 +140,23 @@ public static class HubProgram
Console.WriteLine($"Oprettet: {user.UserName}"); Console.WriteLine($"Oprettet: {user.UserName}");
} }
/// <summary><c>SQLInsight.Hub delete-customer &lt;kundenavn&gt; --confirm</c>: sletter alle kundens data og spærrer licenserne.</summary>
private static async Task DeleteCustomerAsync(string[] args)
{
if (args.Length < 2 || !args.Contains("--confirm"))
{
Console.Error.WriteLine("Brug: SQLInsight.Hub delete-customer \"<kundenavn>\" --confirm");
Environment.ExitCode = 1;
return;
}
var app = Build(args.Skip(2).Where(a => a != "--confirm").ToArray());
await using var scope = app.Services.CreateAsyncScope();
var deleted = await scope.ServiceProvider.GetRequiredService<HubMaintenance>().DeleteCustomerAsync(args[1]).ConfigureAwait(false);
Console.WriteLine(deleted ? $"Alle data for {args[1]} er slettet, og licenserne er spærret." : $"Kunden {args[1]} findes ikke.");
Environment.ExitCode = deleted ? 0 : 2;
}
public static async Task MigrateAsync(IServiceProvider services) public static async Task MigrateAsync(IServiceProvider services)
{ {
ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(services);

View file

@ -68,6 +68,12 @@ public sealed partial class InsightRepository
""", new { outboundId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false); """, new { outboundId, error, now = Utc(now), next = nextAttemptAt is { } n ? Utc(n) : (DateTime?)null }).ConfigureAwait(false);
} }
public async Task SetHubErrorAsync(string error, DateTimeOffset now, CancellationToken cancellationToken = default)
{
await using var connection = await OpenAsync(cancellationToken).ConfigureAwait(false);
await connection.ExecuteAsync("UPDATE insight.HubStatus SET LastError = @error, LastErrorAt = @now WHERE Id = 1;", new { error, now = Utc(now) }).ConfigureAwait(false);
}
public async Task SaveHubStatusAsync(HubStatusRow status, CancellationToken cancellationToken = default) public async Task SaveHubStatusAsync(HubStatusRow status, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);

View file

@ -91,4 +91,18 @@ public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixt
Assert.Equal(0, await sync.RunOnceAsync(CancellationToken.None)); Assert.Equal(0, await sync.RunOnceAsync(CancellationToken.None));
Assert.Equal("Expired", (await _repository.GetHubStatusAsync())!.LicenseState); Assert.Equal("Expired", (await _repository.GetHubStatusAsync())!.LicenseState);
} }
[Fact]
public async Task License_is_never_sent_over_plain_http()
{
var options = Options.Create(new HubOptions { Enabled = true, Url = new Uri("http://hub.example.com/"), License = hub.License("Klar A/S") });
options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey;
await _repository.EnqueueSubmissionAsync("x", Guid.NewGuid(), "{}", DateTimeOffset.UtcNow);
var calls = 0;
var sync = new HubSyncService(options, _repository, new TestServerClients(() => { calls++; return hub.Factory.CreateClient(); }), TimeProvider.System, NullLogger<HubSyncService>.Instance);
Assert.Equal(0, await sync.RunOnceAsync(CancellationToken.None));
Assert.Equal(0, calls);
Assert.Contains("https", (await _repository.GetHubStatusAsync())!.LastError, StringComparison.Ordinal);
}
} }

View file

@ -0,0 +1,81 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SQLInsight.Hub.Data;
using SQLInsight.Hub.Maintenance;
namespace SQLInsight.IntegrationTests;
[Trait("Category", "Integration")]
public class HubMaintenanceTests(HubFixture hub) : IClassFixture<HubFixture>
{
private async Task<HttpStatusCode> SubmitAsync(string license, params (string Rule, string Subject, string Severity)[] findings)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings")
{
Content = JsonContent.Create(HubApiTests.Submission("sql01", DateTimeOffset.UtcNow, findings)),
};
request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
return (await hub.Factory.CreateClient().SendAsync(request)).StatusCode;
}
private async Task<T> WithDbAsync<T>(Func<HubDbContext, HubMaintenance, Task<T>> action)
{
await using var scope = hub.Factory.Services.CreateAsyncScope();
return await action(scope.ServiceProvider.GetRequiredService<HubDbContext>(), scope.ServiceProvider.GetRequiredService<HubMaintenance>());
}
[Fact]
public async Task Deleting_a_customer_removes_its_data_and_blocks_its_licenses()
{
var name = $"Slet {Guid.NewGuid():N}"[..20];
var license = hub.License(name);
Assert.Equal(HttpStatusCode.OK, await SubmitAsync(license, ("SQLI-DB-007", "database: Sales", "Critical")));
var deleted = await WithDbAsync((_, m) => m.DeleteCustomerAsync(name));
Assert.True(deleted);
var (customers, findings, instances, revoked) = await WithDbAsync(async (db, _) => (
await db.Customers.CountAsync(c => c.Name == name),
await db.Findings.CountAsync(f => f.Instance!.Customer!.Name == name),
await db.Instances.CountAsync(i => i.Customer!.Name == name),
await db.Licenses.Where(l => l.Customer!.Name.StartsWith("Slettet kunde #")).CountAsync(l => l.RevokedAt != null)));
Assert.Equal(0, customers);
Assert.Equal(0, findings);
Assert.Equal(0, instances);
Assert.True(revoked >= 1);
Assert.Equal(HttpStatusCode.Forbidden, await SubmitAsync(license, ("SQLI-DB-007", "database: Sales", "Critical")));
}
[Fact]
public async Task Retention_removes_old_resolved_findings_old_submissions_and_silent_instances()
{
var active = $"Aktiv {Guid.NewGuid():N}"[..20];
var silent = $"Tavs {Guid.NewGuid():N}"[..20];
await SubmitAsync(hub.License(active), ("SQLI-CFG-001", "", "High"), ("SQLI-DB-007", "database: A", "Critical"));
await SubmitAsync(hub.License(silent), ("SQLI-CFG-001", "", "High"));
await WithDbAsync(async (db, _) =>
{
var old = DateTime.UtcNow.AddDays(-400);
await db.Findings.Where(f => f.Instance!.Customer!.Name == active && f.RuleId == "SQLI-DB-007")
.ExecuteUpdateAsync(f => f.SetProperty(x => x.ResolvedAt, old));
await db.Submissions.Where(s => db.Instances.Any(i => i.Id == s.InstanceId && i.Customer!.Name == active))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ReceivedAt, old));
await db.Instances.Where(i => i.Customer!.Name == silent).ExecuteUpdateAsync(i => i.SetProperty(x => x.LastSubmissionAt, old));
return 0;
});
var result = await WithDbAsync((_, m) => m.ApplyRetentionAsync());
Assert.True(result.Findings >= 1 && result.Submissions >= 1 && result.Instances >= 1);
var (activeFindings, activeSubmissions, silentInstances) = await WithDbAsync(async (db, _) => (
await db.Findings.Where(f => f.Instance!.Customer!.Name == active).Select(f => f.RuleId).ToListAsync(),
await db.Submissions.CountAsync(s => db.Instances.Any(i => i.Id == s.InstanceId && i.Customer!.Name == active)),
await db.Instances.CountAsync(i => i.Customer!.Name == silent)));
Assert.Equal(["SQLI-CFG-001"], activeFindings);
Assert.Equal(0, activeSubmissions);
Assert.Equal(0, silentInstances);
}
}