From 0fcc31b509d8ecc577ac7878af02e6b75b10efb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:44:09 +0000 Subject: [PATCH] Fase 3: SQLInsight Hub on PostgreSQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ASP.NET Core API POST /api/v1/findings with EF Core (Npgsql, snake_case) and a generated initial migration - The hub is the authority on licenses: verifies the signature itself, rejects invalid (401), community/expired past grace (403), revoked (403, re-checked under the lock) and over the instance limit (403) - Full-state reconciliation per instance: new findings inserted, existing updated, missing ones resolved, rule states upserted; idempotent by submission id; late older submissions are recorded but not applied - Serialized per customer with a PostgreSQL advisory lock (found by a concurrency test: parallel first submissions raced on the license row) - Size and schema validation; tests on real PostgreSQL, including the full path agent → queue → hub → PostgreSQL with evidence redaction Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK --- Directory.Packages.props | 6 + src/SQLInsight.Hub/Api/FindingsEndpoint.cs | 214 ++++++++ src/SQLInsight.Hub/Data/DesignTimeFactory.cs | 11 + src/SQLInsight.Hub/Data/Entities.cs | 120 +++++ src/SQLInsight.Hub/Data/HubDbContext.cs | 68 +++ .../Data/Migrations/.editorconfig | 3 + .../20260929083733_Initial.Designer.cs | 488 ++++++++++++++++++ .../Data/Migrations/20260929083733_Initial.cs | 273 ++++++++++ .../Migrations/HubDbContextModelSnapshot.cs | 485 +++++++++++++++++ src/SQLInsight.Hub/HubOptions.cs | 15 + src/SQLInsight.Hub/Program.cs | 47 +- .../Properties/launchSettings.json | 23 - src/SQLInsight.Hub/SQLInsight.Hub.csproj | 10 + .../appsettings.Development.json | 8 - src/SQLInsight.Hub/appsettings.json | 15 +- .../HubApiTests.cs | 150 ++++++ .../HubEndToEndTests.cs | 94 ++++ .../SQLInsight.IntegrationTests/HubFixture.cs | 56 ++ .../SQLInsight.IntegrationTests.csproj | 2 + 19 files changed, 2050 insertions(+), 38 deletions(-) create mode 100644 src/SQLInsight.Hub/Api/FindingsEndpoint.cs create mode 100644 src/SQLInsight.Hub/Data/DesignTimeFactory.cs create mode 100644 src/SQLInsight.Hub/Data/Entities.cs create mode 100644 src/SQLInsight.Hub/Data/HubDbContext.cs create mode 100644 src/SQLInsight.Hub/Data/Migrations/.editorconfig create mode 100644 src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.Designer.cs create mode 100644 src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.cs create mode 100644 src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs create mode 100644 src/SQLInsight.Hub/HubOptions.cs delete mode 100644 src/SQLInsight.Hub/Properties/launchSettings.json delete mode 100644 src/SQLInsight.Hub/appsettings.Development.json create mode 100644 tests/SQLInsight.IntegrationTests/HubApiTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs create mode 100644 tests/SQLInsight.IntegrationTests/HubFixture.cs 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 create) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => create(); + } + + [Fact] + public async Task Agent_state_reaches_the_hub_with_sensitive_evidence_redacted() + { + var options = Options.Create(new HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = hub.License("E2E A/S") }); + options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey; + var outbox = new HubOutbox(options, _repository); + var monitor = new InstanceMonitor( + new MonitoredInstance + { + Name = "e2e-sql01", Server = sql.Server, Authentication = AuthenticationMode.SqlLogin, UserName = SqlServerFixture.AgentLogin, + Password = SqlServerFixture.AgentPassword, Provider = ConnectionProvider.SqlClient, TrustServerCertificate = true, + }, + BuiltInCollectors.Create(), new Dictionary(), new SqlConnectionFactory(), new RuleEngine(BuiltInRules.Create()), + _repository, NullLogger.Instance, hubOutbox: outbox); + var sync = new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger.Instance); + + await monitor.RunCycleAsync(DateTimeOffset.UtcNow, CancellationToken.None); + var sent = await sync.RunOnceAsync(CancellationToken.None); + + Assert.Equal(1, sent); + var status = await _repository.GetHubStatusAsync(); + Assert.Equal("Valid", status!.LicenseState); + Assert.Equal(0, status.Pending); + Assert.NotNull(status.LastSentAt); + + await using var scope = hub.Factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var instance = await db.Instances.Include(i => i.Customer).SingleAsync(i => i.InstanceKey == "e2e-sql01"); + Assert.Equal("E2E A/S", instance.Customer!.Name); + Assert.Null(instance.ServerName); // not sent by default + var findings = await db.Findings.Where(f => f.InstanceId == instance.Id && f.ResolvedAt == null).ToListAsync(); + Assert.Contains(findings, f => f.RuleId == "SQLI-CFG-001"); + Assert.True(await db.RuleStates.CountAsync(r => r.InstanceId == instance.Id) >= BuiltInRules.Create().Count); + + var latest = Assert.Single(await _repository.GetLatestSubmissionsAsync()); + Assert.Equal("e2e-sql01", latest.InstanceName); + Assert.NotNull(latest.SentAt); + } + + [Fact] + public async Task Rejected_license_is_given_up_not_retried_forever() + { + var community = hub.License("Nope A/S", SQLInsight.Licensing.LicensePlan.Professional, expires: DateTimeOffset.UtcNow.AddDays(-45)); + var options = Options.Create(new HubOptions { Enabled = true, Url = new Uri("http://localhost/"), License = community }); + options.Value.TrustedKeys[HubFixture.KeyId] = hub.PublicKey; + await _repository.EnqueueSubmissionAsync("x", Guid.NewGuid(), "{}", DateTimeOffset.UtcNow); + var sync = new HubSyncService(options, _repository, new TestServerClients(() => hub.Factory.CreateClient()), TimeProvider.System, NullLogger.Instance); + + // Locally the agent sees the license is long expired and does not even try. + Assert.Equal(0, await sync.RunOnceAsync(CancellationToken.None)); + Assert.Equal("Expired", (await _repository.GetHubStatusAsync())!.LicenseState); + } +} diff --git a/tests/SQLInsight.IntegrationTests/HubFixture.cs b/tests/SQLInsight.IntegrationTests/HubFixture.cs new file mode 100644 index 0000000..20eb164 --- /dev/null +++ b/tests/SQLInsight.IntegrationTests/HubFixture.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using SQLInsight.Hub; +using SQLInsight.Licensing; +using Testcontainers.PostgreSql; + +namespace SQLInsight.IntegrationTests; + +/// +/// SQLInsight Hub in-process on a real PostgreSQL, with a test signing key the hub trusts. +/// +public sealed class HubFixture : IAsyncLifetime +{ + public const string KeyId = "itest"; + + private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:17-alpine").Build(); + private readonly ECDsa _key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + + public WebApplicationFactory Factory { get; private set; } = null!; + + public string PublicKey => Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo()); + + public string ConnectionString => _postgres.GetConnectionString(); + + public async Task InitializeAsync() + { + await _postgres.StartAsync(); + Factory = new WebApplicationFactory().WithWebHostBuilder(b => + { + b.UseEnvironment("Development"); + b.UseSetting("ConnectionStrings:Hub", ConnectionString); + b.UseSetting($"SQLInsight:Hub:TrustedKeys:{KeyId}", PublicKey); + }); + await HubProgram.MigrateAsync(Factory.Services); + } + + public string License(string customer, LicensePlan plan = LicensePlan.Professional, int maxInstances = 0, DateTimeOffset? expires = null, string? id = null) => + SignedDocument.Sign(new License + { + LicenseId = id ?? Guid.NewGuid().ToString("N"), + Customer = customer, + Plan = plan, + IssuedAt = DateTimeOffset.UtcNow.AddDays(-1), + ExpiresAt = expires ?? DateTimeOffset.UtcNow.AddYears(1), + MaxInstances = maxInstances, + KeyId = KeyId, + }, _key); + + public async Task DisposeAsync() + { + await Factory.DisposeAsync(); + await _postgres.DisposeAsync(); + _key.Dispose(); + } +} diff --git a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj index a3e8523..b20412f 100644 --- a/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj +++ b/tests/SQLInsight.IntegrationTests/SQLInsight.IntegrationTests.csproj @@ -4,6 +4,7 @@ + @@ -14,6 +15,7 @@ +