diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7dd8fd2..2fc5a8d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,6 +8,12 @@
+
+
+
+
+
+
diff --git a/src/SQLInsight.Hub/Api/FindingsEndpoint.cs b/src/SQLInsight.Hub/Api/FindingsEndpoint.cs
new file mode 100644
index 0000000..e41d2f3
--- /dev/null
+++ b/src/SQLInsight.Hub/Api/FindingsEndpoint.cs
@@ -0,0 +1,214 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using SQLInsight.Contracts;
+using SQLInsight.Hub.Data;
+using SQLInsight.Licensing;
+
+namespace SQLInsight.Hub.Api;
+
+///
+/// POST /api/v1/findings: an agent's full current state for one instance.
+///
+public static class FindingsEndpoint
+{
+ public const string AuthorizationScheme = "SQLInsight-License";
+
+ private static readonly string[] Severities = ["Info", "Low", "Medium", "High", "Critical"];
+
+ public static void Map(IEndpointRouteBuilder app) =>
+ app.MapPost("/api/v1/findings", HandleAsync).DisableAntiforgery().AllowAnonymous();
+
+ internal static async Task HandleAsync(
+ HttpRequest request, HubDbContext db, IOptions options, TimeProvider time, CancellationToken cancellationToken)
+ {
+ // 1. License: the hub is the authority; the agent's own view is never trusted.
+ var header = request.Headers.Authorization.ToString();
+ if (!header.StartsWith(AuthorizationScheme + " ", StringComparison.Ordinal))
+ {
+ return Problem(401, "Licens mangler", $"Authorization: {AuthorizationScheme} kræves.");
+ }
+
+ var keys = TrustedKeys.Embedded().With(options.Value.TrustedKeys);
+ var status = new LicenseVerifier(keys, time).Check(header[(AuthorizationScheme.Length + 1)..]);
+ if (status.State is LicenseState.Invalid or LicenseState.None)
+ {
+ return Problem(401, "Ugyldig licens", status.Message);
+ }
+
+ if (!status.AllowsFindingsSync)
+ {
+ return Problem(403, "Licensen giver ikke Hub-synkronisering", status.Message);
+ }
+
+ var license = status.License!;
+ var record = await db.Licenses.FindAsync([license.LicenseId], cancellationToken).ConfigureAwait(false);
+ if (record?.RevokedAt is not null)
+ {
+ return Problem(403, "Licensen er spærret", record.RevokedReason ?? "Kontakt SQLInsight-support.");
+ }
+
+ // 2. Body.
+ if (request.ContentLength > options.Value.MaxRequestBytes)
+ {
+ return Problem(413, "For stor", $"Højst {options.Value.MaxRequestBytes} bytes.");
+ }
+
+ FindingsSubmission? submission;
+ try
+ {
+ submission = await JsonSerializer.DeserializeAsync(request.Body, JsonSerializerOptions.Web, cancellationToken).ConfigureAwait(false);
+ }
+ catch (JsonException ex)
+ {
+ return Problem(400, "Ugyldig JSON", ex.Message);
+ }
+
+ if (submission is null || submission.SchemaVersion != FindingsSubmission.CurrentSchemaVersion || string.IsNullOrWhiteSpace(submission.Instance?.InstanceKey))
+ {
+ return Problem(400, "Ugyldig indsendelse", $"SchemaVersion {FindingsSubmission.CurrentSchemaVersion} og InstanceKey kræves.");
+ }
+
+ if (submission.Findings.Count > options.Value.MaxFindingsPerSubmission || submission.Findings.Any(f => !Severities.Contains(f.Severity)))
+ {
+ return Problem(422, "Ugyldige fund", "For mange fund eller ukendt alvor.");
+ }
+
+ // 3. Apply in one transaction, serialized per customer: creating the customer, the license record and a new
+ // instance (checked against the license's limit) must not race. Everything read before this point is re-read.
+ var now = time.GetUtcNow().UtcDateTime;
+ await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await db.Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({license.Customer}, 0))", cancellationToken).ConfigureAwait(false);
+ record = await db.Licenses.SingleOrDefaultAsync(l => l.LicenseId == license.LicenseId, cancellationToken).ConfigureAwait(false);
+ if (record?.RevokedAt is not null)
+ {
+ return Problem(403, "Licensen er spærret", record.RevokedReason ?? "Kontakt SQLInsight-support.");
+ }
+
+ if (await db.Submissions.AnyAsync(s => s.SubmissionId == submission.SubmissionId, cancellationToken).ConfigureAwait(false))
+ {
+ return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: true, ActiveFindings: -1));
+ }
+
+ var customer = await db.Customers.SingleOrDefaultAsync(c => c.Name == license.Customer, cancellationToken).ConfigureAwait(false);
+ if (customer is null)
+ {
+ customer = new Customer { Name = license.Customer, CreatedAt = now };
+ db.Customers.Add(customer);
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ if (record is null)
+ {
+ record = new LicenseRecord
+ {
+ LicenseId = license.LicenseId, CustomerId = customer.Id, Plan = license.Plan.ToString(), IssuedAt = license.IssuedAt.UtcDateTime,
+ ExpiresAt = license.ExpiresAt.UtcDateTime, MaxInstances = license.MaxInstances, FirstSeenAt = now, LastSeenAt = now,
+ };
+ db.Licenses.Add(record);
+ }
+
+ record.LastSeenAt = now;
+
+ var instance = await db.Instances.SingleOrDefaultAsync(i => i.CustomerId == customer.Id && i.InstanceKey == submission.Instance.InstanceKey, cancellationToken).ConfigureAwait(false);
+ if (instance is null)
+ {
+ var count = await db.Instances.CountAsync(i => i.CustomerId == customer.Id, cancellationToken).ConfigureAwait(false);
+ if (license.MaxInstances > 0 && count >= license.MaxInstances)
+ {
+ return Problem(403, "For mange instanser", $"Licensen dækker {license.MaxInstances} instanser.");
+ }
+
+ instance = new Instance { CustomerId = customer.Id, InstanceKey = submission.Instance.InstanceKey };
+ db.Instances.Add(instance);
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var collectedAt = submission.CollectedAt.UtcDateTime;
+ var apply = instance.LastCollectedAt is null || collectedAt > instance.LastCollectedAt;
+ db.Submissions.Add(new Submission
+ {
+ SubmissionId = submission.SubmissionId, InstanceId = instance.Id, ReceivedAt = now, CollectedAt = collectedAt,
+ FindingCount = submission.Findings.Count, Applied = apply,
+ });
+
+ var active = 0;
+ if (apply)
+ {
+ instance.Release = submission.Instance.Release;
+ instance.ProductVersion = submission.Instance.ProductVersion;
+ instance.ProductLevel = submission.Instance.ProductLevel;
+ instance.ProductUpdateLevel = submission.Instance.ProductUpdateLevel;
+ instance.Edition = submission.Instance.Edition;
+ instance.ServerName = submission.Instance.ServerName;
+ instance.AgentVersion = submission.AgentVersion;
+ instance.LastCollectedAt = collectedAt;
+ instance.LastSubmissionAt = now;
+ active = await ReconcileAsync(db, instance.Id, submission, collectedAt, cancellationToken).ConfigureAwait(false);
+ }
+
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ return Results.Ok(new SubmissionReceipt(submission.SubmissionId, Duplicate: !apply, ActiveFindings: active));
+ }
+
+ /// Makes the hub's active findings and rule states for the instance equal to the submission.
+ private static async Task ReconcileAsync(HubDbContext db, int instanceId, FindingsSubmission submission, DateTime collectedAt, CancellationToken cancellationToken)
+ {
+ var existing = await db.Findings.Where(f => f.InstanceId == instanceId && f.ResolvedAt == null).ToListAsync(cancellationToken).ConfigureAwait(false);
+ var byKey = existing.ToDictionary(f => (f.RuleId, f.Subject));
+ var seen = new HashSet<(string, string)>();
+
+ foreach (var dto in submission.Findings)
+ {
+ var key = (dto.RuleId, dto.Subject);
+ if (!seen.Add(key))
+ {
+ continue;
+ }
+
+ var evidence = JsonSerializer.Serialize(dto.Evidence, JsonSerializerOptions.Web);
+ if (byKey.TryGetValue(key, out var finding))
+ {
+ finding.Severity = Array.IndexOf(Severities, dto.Severity);
+ finding.Title = dto.Title;
+ finding.Explanation = dto.Explanation;
+ finding.Recommendation = dto.Recommendation;
+ finding.EvidenceJson = evidence;
+ finding.LastSeenAt = collectedAt;
+ }
+ else
+ {
+ db.Findings.Add(new HubFinding
+ {
+ InstanceId = instanceId, RuleId = dto.RuleId, Subject = dto.Subject, Severity = Array.IndexOf(Severities, dto.Severity),
+ Title = dto.Title, Explanation = dto.Explanation, Recommendation = dto.Recommendation, EvidenceJson = evidence,
+ FirstSeenAt = dto.FirstSeenAt.UtcDateTime, LastSeenAt = collectedAt,
+ });
+ }
+ }
+
+ foreach (var gone in existing.Where(f => !seen.Contains((f.RuleId, f.Subject))))
+ {
+ gone.ResolvedAt = collectedAt;
+ }
+
+ var states = await db.RuleStates.Where(r => r.InstanceId == instanceId).ToDictionaryAsync(r => r.RuleId, cancellationToken).ConfigureAwait(false);
+ foreach (var rule in submission.Rules.DistinctBy(r => r.RuleId))
+ {
+ if (!states.TryGetValue(rule.RuleId, out var state))
+ {
+ state = new RuleState { InstanceId = instanceId, RuleId = rule.RuleId, Outcome = rule.Outcome };
+ db.RuleStates.Add(state);
+ }
+
+ state.Outcome = rule.Outcome;
+ state.Reason = rule.Reason;
+ state.UpdatedAt = collectedAt;
+ }
+
+ return seen.Count;
+ }
+
+ private static IResult Problem(int status, string title, string detail) => Results.Problem(detail, statusCode: status, title: title);
+}
diff --git a/src/SQLInsight.Hub/Data/DesignTimeFactory.cs b/src/SQLInsight.Hub/Data/DesignTimeFactory.cs
new file mode 100644
index 0000000..8465d9d
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/DesignTimeFactory.cs
@@ -0,0 +1,11 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace SQLInsight.Hub.Data;
+
+/// Lets dotnet ef migrations add build the model without a database or configuration.
+internal sealed class DesignTimeFactory : IDesignTimeDbContextFactory
+{
+ public HubDbContext CreateDbContext(string[] args) =>
+ new(new DbContextOptionsBuilder().UseNpgsql("Host=design-time").UseSnakeCaseNamingConvention().Options);
+}
diff --git a/src/SQLInsight.Hub/Data/Entities.cs b/src/SQLInsight.Hub/Data/Entities.cs
new file mode 100644
index 0000000..3c3c2ff
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/Entities.cs
@@ -0,0 +1,120 @@
+namespace SQLInsight.Hub.Data;
+
+public sealed class Customer
+{
+ public int Id { get; set; }
+ public required string Name { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public List Licenses { get; } = [];
+ public List Instances { get; } = [];
+ public List SupportCases { get; } = [];
+}
+
+public sealed class LicenseRecord
+{
+ public required string LicenseId { get; set; }
+ public int CustomerId { get; set; }
+ public Customer? Customer { get; set; }
+ public required string Plan { get; set; }
+ public DateTime IssuedAt { get; set; }
+ public DateTime ExpiresAt { get; set; }
+ public int MaxInstances { get; set; }
+ public DateTime FirstSeenAt { get; set; }
+ public DateTime LastSeenAt { get; set; }
+
+ /// Set to stop a license from syncing before it expires (e.g. cancelled contract, leaked key).
+ public DateTime? RevokedAt { get; set; }
+
+ public string? RevokedReason { get; set; }
+}
+
+public sealed class Instance
+{
+ public int Id { get; set; }
+ public int CustomerId { get; set; }
+ public Customer? Customer { get; set; }
+ public required string InstanceKey { get; set; }
+ public string? Release { get; set; }
+ public string? ProductVersion { get; set; }
+ public string? ProductLevel { get; set; }
+ public string? ProductUpdateLevel { get; set; }
+ public string? Edition { get; set; }
+ public string? ServerName { get; set; }
+ public string? AgentVersion { get; set; }
+
+ /// When the newest applied state was collected; older submissions arriving late are ignored.
+ public DateTime? LastCollectedAt { get; set; }
+
+ public DateTime? LastSubmissionAt { get; set; }
+ public List Findings { get; } = [];
+}
+
+public sealed class Submission
+{
+ public Guid SubmissionId { get; set; }
+ public int InstanceId { get; set; }
+ public DateTime ReceivedAt { get; set; }
+ public DateTime CollectedAt { get; set; }
+ public int FindingCount { get; set; }
+ public bool Applied { get; set; }
+}
+
+public sealed class HubFinding
+{
+ public long Id { get; set; }
+ public int InstanceId { get; set; }
+ public Instance? Instance { get; set; }
+ public required string RuleId { get; set; }
+ public required string Subject { get; set; }
+
+ /// 0 Info … 4 Critical.
+ public int Severity { get; set; }
+
+ public required string Title { get; set; }
+ public required string Explanation { get; set; }
+ public required string Recommendation { get; set; }
+ public required string EvidenceJson { get; set; }
+ public DateTime FirstSeenAt { get; set; }
+ public DateTime LastSeenAt { get; set; }
+ public DateTime? ResolvedAt { get; set; }
+ public int? SupportCaseId { get; set; }
+ public SupportCase? SupportCase { get; set; }
+}
+
+public sealed class RuleState
+{
+ public int InstanceId { get; set; }
+ public required string RuleId { get; set; }
+ public required string Outcome { get; set; }
+ public string? Reason { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
+
+public sealed class SupportCase
+{
+ public int Id { get; set; }
+ public int CustomerId { get; set; }
+ public Customer? Customer { get; set; }
+
+ /// The case number in the support system, e.g. "SUP-1234".
+ public required string Reference { get; set; }
+
+ public required string Title { get; set; }
+ public string? Note { get; set; }
+ public bool IsOpen { get; set; } = true;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? ClosedAt { get; set; }
+ public List Findings { get; } = [];
+}
+
+/// A vendor employee who may use the portal.
+public sealed class StaffUser
+{
+ public int Id { get; set; }
+ public required string UserName { get; set; }
+ public required string DisplayName { get; set; }
+ public required string PasswordHash { get; set; }
+ public bool IsActive { get; set; } = true;
+ public DateTime CreatedAt { get; set; }
+ public DateTime? LastLoginAt { get; set; }
+}
diff --git a/src/SQLInsight.Hub/Data/HubDbContext.cs b/src/SQLInsight.Hub/Data/HubDbContext.cs
new file mode 100644
index 0000000..cf72c06
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/HubDbContext.cs
@@ -0,0 +1,68 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace SQLInsight.Hub.Data;
+
+public sealed class HubDbContext(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Customers => Set();
+ public DbSet Licenses => Set();
+ public DbSet Instances => Set();
+ public DbSet Submissions => Set();
+ public DbSet Findings => Set();
+ public DbSet RuleStates => Set();
+ public DbSet SupportCases => Set();
+ public DbSet StaffUsers => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(modelBuilder);
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasIndex(c => c.Name).IsUnique();
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(l => l.LicenseId);
+ e.HasOne(l => l.Customer).WithMany(c => c.Licenses).HasForeignKey(l => l.CustomerId);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasIndex(i => new { i.CustomerId, i.InstanceKey }).IsUnique();
+ e.HasOne(i => i.Customer).WithMany(c => c.Instances).HasForeignKey(i => i.CustomerId);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(s => s.SubmissionId);
+ e.HasIndex(s => s.ReceivedAt);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasOne(f => f.Instance).WithMany(i => i.Findings).HasForeignKey(f => f.InstanceId);
+ e.Property(f => f.EvidenceJson).HasColumnType("jsonb");
+ // One active occurrence per instance, rule and subject; history keeps resolved ones.
+ e.HasIndex(f => new { f.InstanceId, f.RuleId, f.Subject }).IsUnique().HasFilter("resolved_at IS NULL");
+ e.HasIndex(f => new { f.Severity, f.ResolvedAt });
+ e.HasOne(f => f.SupportCase).WithMany(c => c.Findings).HasForeignKey(f => f.SupportCaseId).OnDelete(DeleteBehavior.SetNull);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(r => new { r.InstanceId, r.RuleId });
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasOne(c => c.Customer).WithMany(c => c.SupportCases).HasForeignKey(c => c.CustomerId);
+ });
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasIndex(u => u.UserName).IsUnique();
+ });
+ }
+}
diff --git a/src/SQLInsight.Hub/Data/Migrations/.editorconfig b/src/SQLInsight.Hub/Data/Migrations/.editorconfig
new file mode 100644
index 0000000..f089ceb
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/Migrations/.editorconfig
@@ -0,0 +1,3 @@
+# EF Core migrations are generated; do not apply style or quality analyzers to them.
+[*.cs]
+generated_code = true
diff --git a/src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.Designer.cs b/src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.Designer.cs
new file mode 100644
index 0000000..fef8f14
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.Designer.cs
@@ -0,0 +1,488 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SQLInsight.Hub.Data;
+
+#nullable disable
+
+namespace SQLInsight.Hub.Data.Migrations
+{
+ [DbContext(typeof(HubDbContext))]
+ [Migration("20260929083733_Initial")]
+ partial class Initial
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_customers");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_customers_name");
+
+ b.ToTable("customers", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("EvidenceJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("evidence_json");
+
+ b.Property("Explanation")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("explanation");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("InstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("instance_id");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("Recommendation")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recommendation");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("resolved_at");
+
+ b.Property("RuleId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rule_id");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasColumnName("severity");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("subject");
+
+ b.Property("SupportCaseId")
+ .HasColumnType("integer")
+ .HasColumnName("support_case_id");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id")
+ .HasName("pk_findings");
+
+ b.HasIndex("SupportCaseId")
+ .HasDatabaseName("ix_findings_support_case_id");
+
+ b.HasIndex("Severity", "ResolvedAt")
+ .HasDatabaseName("ix_findings_severity_resolved_at");
+
+ b.HasIndex("InstanceId", "RuleId", "Subject")
+ .IsUnique()
+ .HasDatabaseName("ix_findings_instance_id_rule_id_subject")
+ .HasFilter("resolved_at IS NULL");
+
+ b.ToTable("findings", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AgentVersion")
+ .HasColumnType("text")
+ .HasColumnName("agent_version");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("Edition")
+ .HasColumnType("text")
+ .HasColumnName("edition");
+
+ b.Property("InstanceKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("instance_key");
+
+ b.Property("LastCollectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_collected_at");
+
+ b.Property("LastSubmissionAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_submission_at");
+
+ b.Property("ProductLevel")
+ .HasColumnType("text")
+ .HasColumnName("product_level");
+
+ b.Property("ProductUpdateLevel")
+ .HasColumnType("text")
+ .HasColumnName("product_update_level");
+
+ b.Property("ProductVersion")
+ .HasColumnType("text")
+ .HasColumnName("product_version");
+
+ b.Property("Release")
+ .HasColumnType("text")
+ .HasColumnName("release");
+
+ b.Property("ServerName")
+ .HasColumnType("text")
+ .HasColumnName("server_name");
+
+ b.HasKey("Id")
+ .HasName("pk_instances");
+
+ b.HasIndex("CustomerId", "InstanceKey")
+ .IsUnique()
+ .HasDatabaseName("ix_instances_customer_id_instance_key");
+
+ b.ToTable("instances", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
+ {
+ b.Property("LicenseId")
+ .HasColumnType("text")
+ .HasColumnName("license_id");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("issued_at");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("MaxInstances")
+ .HasColumnType("integer")
+ .HasColumnName("max_instances");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("plan");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("RevokedReason")
+ .HasColumnType("text")
+ .HasColumnName("revoked_reason");
+
+ b.HasKey("LicenseId")
+ .HasName("pk_licenses");
+
+ b.HasIndex("CustomerId")
+ .HasDatabaseName("ix_licenses_customer_id");
+
+ b.ToTable("licenses", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.RuleState", b =>
+ {
+ b.Property("InstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("instance_id");
+
+ b.Property("RuleId")
+ .HasColumnType("text")
+ .HasColumnName("rule_id");
+
+ b.Property("Outcome")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("outcome");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("InstanceId", "RuleId")
+ .HasName("pk_rule_states");
+
+ b.ToTable("rule_states", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.StaffUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("user_name");
+
+ b.HasKey("Id")
+ .HasName("pk_staff_users");
+
+ b.HasIndex("UserName")
+ .IsUnique()
+ .HasDatabaseName("ix_staff_users_user_name");
+
+ b.ToTable("staff_users", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Submission", b =>
+ {
+ b.Property("SubmissionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("submission_id");
+
+ b.Property("Applied")
+ .HasColumnType("boolean")
+ .HasColumnName("applied");
+
+ b.Property("CollectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("collected_at");
+
+ b.Property("FindingCount")
+ .HasColumnType("integer")
+ .HasColumnName("finding_count");
+
+ b.Property("InstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("instance_id");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("received_at");
+
+ b.HasKey("SubmissionId")
+ .HasName("pk_submissions");
+
+ b.HasIndex("ReceivedAt")
+ .HasDatabaseName("ix_submissions_received_at");
+
+ b.ToTable("submissions", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("closed_at");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("IsOpen")
+ .HasColumnType("boolean")
+ .HasColumnName("is_open");
+
+ b.Property("Note")
+ .HasColumnType("text")
+ .HasColumnName("note");
+
+ b.Property("Reference")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("reference");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id")
+ .HasName("pk_support_cases");
+
+ b.HasIndex("CustomerId")
+ .HasDatabaseName("ix_support_cases_customer_id");
+
+ b.ToTable("support_cases", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
+ .WithMany("Findings")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_findings_instances_instance_id");
+
+ b.HasOne("SQLInsight.Hub.Data.SupportCase", "SupportCase")
+ .WithMany("Findings")
+ .HasForeignKey("SupportCaseId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_findings_support_cases_support_case_id");
+
+ b.Navigation("Instance");
+
+ b.Navigation("SupportCase");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
+ .WithMany("Instances")
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_instances_customers_customer_id");
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
+ .WithMany("Licenses")
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_licenses_customers_customer_id");
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
+ .WithMany("SupportCases")
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_support_cases_customers_customer_id");
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
+ {
+ b.Navigation("Instances");
+
+ b.Navigation("Licenses");
+
+ b.Navigation("SupportCases");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
+ {
+ b.Navigation("Findings");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
+ {
+ b.Navigation("Findings");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.cs b/src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.cs
new file mode 100644
index 0000000..995f00a
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.cs
@@ -0,0 +1,273 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace SQLInsight.Hub.Data.Migrations
+{
+ ///
+ public partial class Initial : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "customers",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "text", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_customers", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "rule_states",
+ columns: table => new
+ {
+ instance_id = table.Column(type: "integer", nullable: false),
+ rule_id = table.Column(type: "text", nullable: false),
+ outcome = table.Column(type: "text", nullable: false),
+ reason = table.Column(type: "text", nullable: true),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_rule_states", x => new { x.instance_id, x.rule_id });
+ });
+
+ migrationBuilder.CreateTable(
+ name: "staff_users",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ user_name = table.Column(type: "text", nullable: false),
+ display_name = table.Column(type: "text", nullable: false),
+ password_hash = table.Column(type: "text", nullable: false),
+ is_active = table.Column(type: "boolean", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_login_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_staff_users", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "submissions",
+ columns: table => new
+ {
+ submission_id = table.Column(type: "uuid", nullable: false),
+ instance_id = table.Column(type: "integer", nullable: false),
+ received_at = table.Column(type: "timestamp with time zone", nullable: false),
+ collected_at = table.Column(type: "timestamp with time zone", nullable: false),
+ finding_count = table.Column(type: "integer", nullable: false),
+ applied = table.Column(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_submissions", x => x.submission_id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "instances",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ customer_id = table.Column(type: "integer", nullable: false),
+ instance_key = table.Column(type: "text", nullable: false),
+ release = table.Column(type: "text", nullable: true),
+ product_version = table.Column(type: "text", nullable: true),
+ product_level = table.Column(type: "text", nullable: true),
+ product_update_level = table.Column(type: "text", nullable: true),
+ edition = table.Column(type: "text", nullable: true),
+ server_name = table.Column(type: "text", nullable: true),
+ agent_version = table.Column(type: "text", nullable: true),
+ last_collected_at = table.Column(type: "timestamp with time zone", nullable: true),
+ last_submission_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_instances", x => x.id);
+ table.ForeignKey(
+ name: "fk_instances_customers_customer_id",
+ column: x => x.customer_id,
+ principalTable: "customers",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "licenses",
+ columns: table => new
+ {
+ license_id = table.Column(type: "text", nullable: false),
+ customer_id = table.Column(type: "integer", nullable: false),
+ plan = table.Column(type: "text", nullable: false),
+ issued_at = table.Column(type: "timestamp with time zone", nullable: false),
+ expires_at = table.Column(type: "timestamp with time zone", nullable: false),
+ max_instances = table.Column(type: "integer", nullable: false),
+ first_seen_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_seen_at = table.Column(type: "timestamp with time zone", nullable: false),
+ revoked_at = table.Column(type: "timestamp with time zone", nullable: true),
+ revoked_reason = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_licenses", x => x.license_id);
+ table.ForeignKey(
+ name: "fk_licenses_customers_customer_id",
+ column: x => x.customer_id,
+ principalTable: "customers",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "support_cases",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ customer_id = table.Column(type: "integer", nullable: false),
+ reference = table.Column(type: "text", nullable: false),
+ title = table.Column(type: "text", nullable: false),
+ note = table.Column(type: "text", nullable: true),
+ is_open = table.Column(type: "boolean", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ closed_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_support_cases", x => x.id);
+ table.ForeignKey(
+ name: "fk_support_cases_customers_customer_id",
+ column: x => x.customer_id,
+ principalTable: "customers",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "findings",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ instance_id = table.Column(type: "integer", nullable: false),
+ rule_id = table.Column(type: "text", nullable: false),
+ subject = table.Column(type: "text", nullable: false),
+ severity = table.Column(type: "integer", nullable: false),
+ title = table.Column(type: "text", nullable: false),
+ explanation = table.Column(type: "text", nullable: false),
+ recommendation = table.Column(type: "text", nullable: false),
+ evidence_json = table.Column(type: "jsonb", nullable: false),
+ first_seen_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_seen_at = table.Column(type: "timestamp with time zone", nullable: false),
+ resolved_at = table.Column(type: "timestamp with time zone", nullable: true),
+ support_case_id = table.Column(type: "integer", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_findings", x => x.id);
+ table.ForeignKey(
+ name: "fk_findings_instances_instance_id",
+ column: x => x.instance_id,
+ principalTable: "instances",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_findings_support_cases_support_case_id",
+ column: x => x.support_case_id,
+ principalTable: "support_cases",
+ principalColumn: "id",
+ onDelete: ReferentialAction.SetNull);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_customers_name",
+ table: "customers",
+ column: "name",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_findings_instance_id_rule_id_subject",
+ table: "findings",
+ columns: new[] { "instance_id", "rule_id", "subject" },
+ unique: true,
+ filter: "resolved_at IS NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_findings_severity_resolved_at",
+ table: "findings",
+ columns: new[] { "severity", "resolved_at" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_findings_support_case_id",
+ table: "findings",
+ column: "support_case_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_instances_customer_id_instance_key",
+ table: "instances",
+ columns: new[] { "customer_id", "instance_key" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_licenses_customer_id",
+ table: "licenses",
+ column: "customer_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_staff_users_user_name",
+ table: "staff_users",
+ column: "user_name",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_submissions_received_at",
+ table: "submissions",
+ column: "received_at");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_support_cases_customer_id",
+ table: "support_cases",
+ column: "customer_id");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "findings");
+
+ migrationBuilder.DropTable(
+ name: "licenses");
+
+ migrationBuilder.DropTable(
+ name: "rule_states");
+
+ migrationBuilder.DropTable(
+ name: "staff_users");
+
+ migrationBuilder.DropTable(
+ name: "submissions");
+
+ migrationBuilder.DropTable(
+ name: "instances");
+
+ migrationBuilder.DropTable(
+ name: "support_cases");
+
+ migrationBuilder.DropTable(
+ name: "customers");
+ }
+ }
+}
diff --git a/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs b/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs
new file mode 100644
index 0000000..0f293c8
--- /dev/null
+++ b/src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs
@@ -0,0 +1,485 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SQLInsight.Hub.Data;
+
+#nullable disable
+
+namespace SQLInsight.Hub.Data.Migrations
+{
+ [DbContext(typeof(HubDbContext))]
+ partial class HubDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.HasKey("Id")
+ .HasName("pk_customers");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_customers_name");
+
+ b.ToTable("customers", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("EvidenceJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("evidence_json");
+
+ b.Property("Explanation")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("explanation");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("InstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("instance_id");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("Recommendation")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("recommendation");
+
+ b.Property("ResolvedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("resolved_at");
+
+ b.Property("RuleId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rule_id");
+
+ b.Property("Severity")
+ .HasColumnType("integer")
+ .HasColumnName("severity");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("subject");
+
+ b.Property("SupportCaseId")
+ .HasColumnType("integer")
+ .HasColumnName("support_case_id");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id")
+ .HasName("pk_findings");
+
+ b.HasIndex("SupportCaseId")
+ .HasDatabaseName("ix_findings_support_case_id");
+
+ b.HasIndex("Severity", "ResolvedAt")
+ .HasDatabaseName("ix_findings_severity_resolved_at");
+
+ b.HasIndex("InstanceId", "RuleId", "Subject")
+ .IsUnique()
+ .HasDatabaseName("ix_findings_instance_id_rule_id_subject")
+ .HasFilter("resolved_at IS NULL");
+
+ b.ToTable("findings", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AgentVersion")
+ .HasColumnType("text")
+ .HasColumnName("agent_version");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("Edition")
+ .HasColumnType("text")
+ .HasColumnName("edition");
+
+ b.Property("InstanceKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("instance_key");
+
+ b.Property("LastCollectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_collected_at");
+
+ b.Property("LastSubmissionAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_submission_at");
+
+ b.Property("ProductLevel")
+ .HasColumnType("text")
+ .HasColumnName("product_level");
+
+ b.Property("ProductUpdateLevel")
+ .HasColumnType("text")
+ .HasColumnName("product_update_level");
+
+ b.Property("ProductVersion")
+ .HasColumnType("text")
+ .HasColumnName("product_version");
+
+ b.Property("Release")
+ .HasColumnType("text")
+ .HasColumnName("release");
+
+ b.Property("ServerName")
+ .HasColumnType("text")
+ .HasColumnName("server_name");
+
+ b.HasKey("Id")
+ .HasName("pk_instances");
+
+ b.HasIndex("CustomerId", "InstanceKey")
+ .IsUnique()
+ .HasDatabaseName("ix_instances_customer_id_instance_key");
+
+ b.ToTable("instances", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
+ {
+ b.Property("LicenseId")
+ .HasColumnType("text")
+ .HasColumnName("license_id");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("IssuedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("issued_at");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("MaxInstances")
+ .HasColumnType("integer")
+ .HasColumnName("max_instances");
+
+ b.Property("Plan")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("plan");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at");
+
+ b.Property("RevokedReason")
+ .HasColumnType("text")
+ .HasColumnName("revoked_reason");
+
+ b.HasKey("LicenseId")
+ .HasName("pk_licenses");
+
+ b.HasIndex("CustomerId")
+ .HasDatabaseName("ix_licenses_customer_id");
+
+ b.ToTable("licenses", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.RuleState", b =>
+ {
+ b.Property("InstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("instance_id");
+
+ b.Property("RuleId")
+ .HasColumnType("text")
+ .HasColumnName("rule_id");
+
+ b.Property("Outcome")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("outcome");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("InstanceId", "RuleId")
+ .HasName("pk_rule_states");
+
+ b.ToTable("rule_states", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.StaffUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("DisplayName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("user_name");
+
+ b.HasKey("Id")
+ .HasName("pk_staff_users");
+
+ b.HasIndex("UserName")
+ .IsUnique()
+ .HasDatabaseName("ix_staff_users_user_name");
+
+ b.ToTable("staff_users", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Submission", b =>
+ {
+ b.Property("SubmissionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("submission_id");
+
+ b.Property("Applied")
+ .HasColumnType("boolean")
+ .HasColumnName("applied");
+
+ b.Property("CollectedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("collected_at");
+
+ b.Property("FindingCount")
+ .HasColumnType("integer")
+ .HasColumnName("finding_count");
+
+ b.Property("InstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("instance_id");
+
+ b.Property("ReceivedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("received_at");
+
+ b.HasKey("SubmissionId")
+ .HasName("pk_submissions");
+
+ b.HasIndex("ReceivedAt")
+ .HasDatabaseName("ix_submissions_received_at");
+
+ b.ToTable("submissions", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClosedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("closed_at");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("CustomerId")
+ .HasColumnType("integer")
+ .HasColumnName("customer_id");
+
+ b.Property("IsOpen")
+ .HasColumnType("boolean")
+ .HasColumnName("is_open");
+
+ b.Property("Note")
+ .HasColumnType("text")
+ .HasColumnName("note");
+
+ b.Property("Reference")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("reference");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.HasKey("Id")
+ .HasName("pk_support_cases");
+
+ b.HasIndex("CustomerId")
+ .HasDatabaseName("ix_support_cases_customer_id");
+
+ b.ToTable("support_cases", (string)null);
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.HubFinding", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Instance", "Instance")
+ .WithMany("Findings")
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_findings_instances_instance_id");
+
+ b.HasOne("SQLInsight.Hub.Data.SupportCase", "SupportCase")
+ .WithMany("Findings")
+ .HasForeignKey("SupportCaseId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_findings_support_cases_support_case_id");
+
+ b.Navigation("Instance");
+
+ b.Navigation("SupportCase");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
+ .WithMany("Instances")
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_instances_customers_customer_id");
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.LicenseRecord", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
+ .WithMany("Licenses")
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_licenses_customers_customer_id");
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
+ {
+ b.HasOne("SQLInsight.Hub.Data.Customer", "Customer")
+ .WithMany("SupportCases")
+ .HasForeignKey("CustomerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_support_cases_customers_customer_id");
+
+ b.Navigation("Customer");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Customer", b =>
+ {
+ b.Navigation("Instances");
+
+ b.Navigation("Licenses");
+
+ b.Navigation("SupportCases");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.Instance", b =>
+ {
+ b.Navigation("Findings");
+ });
+
+ modelBuilder.Entity("SQLInsight.Hub.Data.SupportCase", b =>
+ {
+ b.Navigation("Findings");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/SQLInsight.Hub/HubOptions.cs b/src/SQLInsight.Hub/HubOptions.cs
new file mode 100644
index 0000000..33f9a29
--- /dev/null
+++ b/src/SQLInsight.Hub/HubOptions.cs
@@ -0,0 +1,15 @@
+namespace SQLInsight.Hub;
+
+public sealed class HubServerOptions
+{
+ public const string SectionName = "SQLInsight:Hub";
+
+ /// Public keys trusted for licenses in addition to the embedded ones (key id → base64 SubjectPublicKeyInfo).
+ public IDictionary TrustedKeys { get; } = new Dictionary();
+
+ /// Largest accepted request body.
+ public long MaxRequestBytes { get; set; } = 5 * 1024 * 1024;
+
+ /// Most findings accepted per submission.
+ public int MaxFindingsPerSubmission { get; set; } = 2000;
+}
diff --git a/src/SQLInsight.Hub/Program.cs b/src/SQLInsight.Hub/Program.cs
index 0defc95..670f182 100644
--- a/src/SQLInsight.Hub/Program.cs
+++ b/src/SQLInsight.Hub/Program.cs
@@ -1,7 +1,44 @@
-// Central ingestion API for findings from paying customers, backed by PostgreSQL. Placeholder until Fase 3.
-var builder = WebApplication.CreateBuilder(args);
-var app = builder.Build();
+using Microsoft.EntityFrameworkCore;
+using SQLInsight.Hub;
+using SQLInsight.Hub.Api;
+using SQLInsight.Hub.Data;
-app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
+namespace SQLInsight.Hub;
-app.Run();
+///
+/// SQLInsight Hub: receives findings from paying customers' agents into PostgreSQL, and serves the vendor portal.
+///
+public static class HubProgram
+{
+ public static async Task Main(string[] args)
+ {
+ var app = Build(args);
+ await MigrateAsync(app.Services).ConfigureAwait(false);
+ await app.RunAsync().ConfigureAwait(false);
+ }
+
+ public static WebApplication Build(string[] args)
+ {
+ var builder = WebApplication.CreateBuilder(args);
+ builder.Services.AddWindowsService(o => o.ServiceName = "SQLInsight Hub");
+ builder.Services.AddSystemd();
+ builder.Services.Configure(builder.Configuration.GetSection(HubServerOptions.SectionName));
+ builder.Services.AddSingleton(TimeProvider.System);
+
+ var connectionString = builder.Configuration.GetConnectionString("Hub")
+ ?? throw new InvalidOperationException("ConnectionStrings:Hub mangler (PostgreSQL).");
+ builder.Services.AddDbContext(o => o.UseNpgsql(connectionString).UseSnakeCaseNamingConvention());
+
+ var app = builder.Build();
+ app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
+ FindingsEndpoint.Map(app);
+ return app;
+ }
+
+ public static async Task MigrateAsync(IServiceProvider services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ await using var scope = services.CreateAsyncScope();
+ await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync().ConfigureAwait(false);
+ }
+}
diff --git a/src/SQLInsight.Hub/Properties/launchSettings.json b/src/SQLInsight.Hub/Properties/launchSettings.json
deleted file mode 100644
index f5612e1..0000000
--- a/src/SQLInsight.Hub/Properties/launchSettings.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "$schema": "https://json.schemastore.org/launchsettings.json",
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "http://localhost:5296",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "https://localhost:7203;http://localhost:5296",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
-}
diff --git a/src/SQLInsight.Hub/SQLInsight.Hub.csproj b/src/SQLInsight.Hub/SQLInsight.Hub.csproj
index 8027d54..2e041a5 100644
--- a/src/SQLInsight.Hub/SQLInsight.Hub.csproj
+++ b/src/SQLInsight.Hub/SQLInsight.Hub.csproj
@@ -4,5 +4,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SQLInsight.Hub/appsettings.Development.json b/src/SQLInsight.Hub/appsettings.Development.json
deleted file mode 100644
index 0c208ae..0000000
--- a/src/SQLInsight.Hub/appsettings.Development.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
diff --git a/src/SQLInsight.Hub/appsettings.json b/src/SQLInsight.Hub/appsettings.json
index 10f68b8..b509460 100644
--- a/src/SQLInsight.Hub/appsettings.json
+++ b/src/SQLInsight.Hub/appsettings.json
@@ -2,8 +2,19 @@
"Logging": {
"LogLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "Hub": "Host=localhost;Database=sqlinsight_hub;Username=sqlinsight_hub;Password=skift-mig"
+ },
+ "SQLInsight": {
+ "Hub": {
+ "TrustedKeys": {},
+ "MaxRequestBytes": 5242880,
+ "MaxFindingsPerSubmission": 2000
+ }
+ }
}
diff --git a/tests/SQLInsight.IntegrationTests/HubApiTests.cs b/tests/SQLInsight.IntegrationTests/HubApiTests.cs
new file mode 100644
index 0000000..44804ab
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/HubApiTests.cs
@@ -0,0 +1,150 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SQLInsight.Contracts;
+using SQLInsight.Hub.Data;
+using SQLInsight.Licensing;
+
+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()
+ {
+ SubmissionId = Guid.NewGuid(),
+ AgentVersion = "1.0.0",
+ CollectedAt = collectedAt,
+ Instance = new InstanceSnapshot { InstanceKey = instance, Release = "SQL Server 2019", ProductVersion = "15.0.4490.9", Edition = "Enterprise" },
+ Rules = findings.Select(f => f.Rule).Distinct().Select(r => new RuleOutcomeDto { RuleId = r, Outcome = "Findings" }).ToList(),
+ Findings = findings.Select(f => new FindingDto
+ {
+ RuleId = f.Rule, Subject = f.Subject, Severity = f.Severity, Title = $"{f.Rule} {f.Subject}", Explanation = "e", Recommendation = "r",
+ FirstSeenAt = collectedAt.AddHours(-1), Evidence = [new EvidenceDto("Måling", "42")],
+ }).ToList(),
+ };
+
+ private async Task PostAsync(string license, object body)
+ {
+ var client = hub.Factory.CreateClient();
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/findings") { Content = JsonContent.Create(body) };
+ request.Headers.Authorization = new AuthenticationHeaderValue("SQLInsight-License", license);
+ return await client.SendAsync(request);
+ }
+
+ private async Task QueryAsync(Func> query)
+ {
+ await using var scope = hub.Factory.Services.CreateAsyncScope();
+ return await query(scope.ServiceProvider.GetRequiredService());
+ }
+
+ [Fact]
+ public async Task Full_state_is_reconciled_idempotently()
+ {
+ var license = hub.License("Reconcile A/S");
+ var t0 = DateTimeOffset.UtcNow.AddMinutes(-30);
+ var first = Submission("sql01", t0, ("SQLI-CFG-001", "", "High"), ("SQLI-DB-007", "database: Sales", "Critical"));
+
+ var response = await PostAsync(license, first);
+ var again = await PostAsync(license, first);
+ var second = await PostAsync(license, Submission("sql01", t0.AddMinutes(15), ("SQLI-CFG-001", "", "Medium")));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.True((await again.Content.ReadFromJsonAsync())!.Duplicate);
+ Assert.Equal(1, (await second.Content.ReadFromJsonAsync())!.ActiveFindings);
+
+ var findings = await QueryAsync(db => db.Findings.Include(f => f.Instance!).ThenInclude(i => i.Customer)
+ .Where(f => f.Instance!.Customer!.Name == "Reconcile A/S").ToListAsync());
+ Assert.Equal(2, findings.Count);
+ var cfg = Assert.Single(findings, f => f.RuleId == "SQLI-CFG-001");
+ Assert.Null(cfg.ResolvedAt);
+ Assert.Equal(2, cfg.Severity); // Medium now
+ Assert.NotNull(Assert.Single(findings, f => f.RuleId == "SQLI-DB-007").ResolvedAt);
+ }
+
+ [Fact]
+ public async Task Late_older_submission_is_not_applied()
+ {
+ var license = hub.License("Order A/S");
+ var now = DateTimeOffset.UtcNow;
+ await PostAsync(license, Submission("sql01", now, ("SQLI-CFG-001", "", "High")));
+
+ var late = await PostAsync(license, Submission("sql01", now.AddMinutes(-15)));
+
+ Assert.True((await late.Content.ReadFromJsonAsync())!.Duplicate);
+ Assert.Equal(1, await QueryAsync(db => db.Findings.CountAsync(f => f.Instance!.Customer!.Name == "Order A/S" && f.ResolvedAt == null)));
+ }
+
+ [Fact]
+ public async Task Concurrent_submissions_for_one_instance_do_not_conflict()
+ {
+ var license = hub.License("Parallel A/S");
+ var start = DateTimeOffset.UtcNow.AddMinutes(-10);
+
+ var responses = await Task.WhenAll(Enumerable.Range(0, 8).Select(i =>
+ PostAsync(license, Submission("sql01", start.AddSeconds(i), ("SQLI-CFG-001", "", "High"), ("SQLI-DB-002", $"database: D{i % 2}", "High")))));
+
+ Assert.All(responses, r => Assert.Equal(HttpStatusCode.OK, r.StatusCode));
+ Assert.Equal(1, await QueryAsync(db => db.Findings.CountAsync(f => f.Instance!.Customer!.Name == "Parallel A/S" && f.RuleId == "SQLI-CFG-001" && f.ResolvedAt == null)));
+ }
+
+ [Theory]
+ [InlineData("garbage", HttpStatusCode.Unauthorized)]
+ [InlineData("", HttpStatusCode.Unauthorized)]
+ public async Task Invalid_license_is_rejected(string license, HttpStatusCode expected)
+ {
+ Assert.Equal(expected, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode);
+ }
+
+ [Fact]
+ public async Task Community_and_long_expired_licenses_cannot_sync()
+ {
+ var community = hub.License("Free A/S", LicensePlan.Community);
+ var expired = hub.License("Old A/S", expires: DateTimeOffset.UtcNow.AddDays(-45));
+
+ Assert.Equal(HttpStatusCode.Forbidden, (await PostAsync(community, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, (await PostAsync(expired, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode);
+ }
+
+ [Fact]
+ public async Task Revoked_license_is_rejected()
+ {
+ var id = Guid.NewGuid().ToString("N");
+ var license = hub.License("Revoked A/S", id: id);
+ Assert.Equal(HttpStatusCode.OK, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode);
+
+ await QueryAsync(async db =>
+ {
+ var record = await db.Licenses.SingleAsync(l => l.LicenseId == id);
+ record.RevokedAt = DateTime.UtcNow;
+ record.RevokedReason = "Kontrakt opsagt";
+ return await db.SaveChangesAsync();
+ });
+
+ var response = await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow.AddMinutes(1)));
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Contains("Kontrakt opsagt", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Instance_limit_is_enforced()
+ {
+ var license = hub.License("Small A/S", maxInstances: 1);
+
+ Assert.Equal(HttpStatusCode.OK, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow))).StatusCode);
+ Assert.Equal(HttpStatusCode.OK, (await PostAsync(license, Submission("sql01", DateTimeOffset.UtcNow.AddMinutes(1)))).StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, (await PostAsync(license, Submission("sql02", DateTimeOffset.UtcNow))).StatusCode);
+ }
+
+ [Fact]
+ public async Task Malformed_submissions_are_rejected()
+ {
+ var license = hub.License("Strict A/S");
+ var badSeverity = Submission("sql01", DateTimeOffset.UtcNow, ("SQLI-CFG-001", "", "Catastrophic"));
+
+ Assert.Equal(HttpStatusCode.BadRequest, (await PostAsync(license, new { schemaVersion = 99 })).StatusCode);
+ Assert.Equal(HttpStatusCode.UnprocessableEntity, (await PostAsync(license, badSeverity)).StatusCode);
+ }
+}
diff --git a/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
new file mode 100644
index 0000000..5c3427a
--- /dev/null
+++ b/tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
@@ -0,0 +1,94 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using SQLInsight.Agent;
+using SQLInsight.Agent.Hub;
+using SQLInsight.Agent.Monitoring;
+using SQLInsight.Collectors;
+using SQLInsight.Connectivity;
+using SQLInsight.Contracts;
+using SQLInsight.Hub.Data;
+using SQLInsight.Repository;
+using SQLInsight.Rules;
+using SQLInsight.Rules.Engine;
+
+namespace SQLInsight.IntegrationTests;
+
+///
+/// The whole paid path: agent monitors real SQL Server, queues its state, delivers it to the hub, hub stores it in PostgreSQL.
+///
+[Collection(SqlServerTestGroup.Name)]
+[Trait("Category", "Integration")]
+public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixture, IAsyncLifetime
+{
+ private readonly string _database = "SQLInsightE2E_" + Guid.NewGuid().ToString("N")[..8];
+ private InsightRepository _repository = null!;
+
+ public async Task InitializeAsync()
+ {
+ await sql.ExecuteAsSaAsync($"CREATE DATABASE [{_database}];");
+ _repository = new InsightRepository(sql.DatabaseConnectionString(_database));
+ await _repository.MigrateAsync();
+ }
+
+ public Task DisposeAsync() => sql.ExecuteAsSaAsync($"ALTER DATABASE [{_database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [{_database}];");
+
+ private sealed class TestServerClients(Func