Fase 3: SQLInsight Hub on PostgreSQL
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
Some checks are pending
CI / Integration (mcr.microsoft.com/mssql/server:2019-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2022-latest) (push) Blocked by required conditions
CI / Build og unit tests (push) Waiting to run
CI / Integration (mcr.microsoft.com/mssql/server:2017-latest) (push) Blocked by required conditions
CI / Integration (mcr.microsoft.com/mssql/server:2025-latest) (push) Blocked by required conditions
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3EExwndy8Z22EK2Qpt6BK
This commit is contained in:
parent
bfb27778d2
commit
0fcc31b509
19 changed files with 2050 additions and 38 deletions
|
|
@ -8,6 +8,12 @@
|
|||
<PackageVersion Include="Microsoft.Data.SqlClient.Extensions.Azure" Version="7.1.0" />
|
||||
<PackageVersion Include="System.Data.Odbc" Version="10.0.12" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.89" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||
<PackageVersion Include="MailKit" Version="4.18.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
|
||||
|
|
|
|||
214
src/SQLInsight.Hub/Api/FindingsEndpoint.cs
Normal file
214
src/SQLInsight.Hub/Api/FindingsEndpoint.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/v1/findings: an agent's full current state for one instance.
|
||||
/// </summary>
|
||||
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<IResult> HandleAsync(
|
||||
HttpRequest request, HubDbContext db, IOptions<HubServerOptions> 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} <licens> 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<FindingsSubmission>(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));
|
||||
}
|
||||
|
||||
/// <summary>Makes the hub's active findings and rule states for the instance equal to the submission.</summary>
|
||||
private static async Task<int> 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);
|
||||
}
|
||||
11
src/SQLInsight.Hub/Data/DesignTimeFactory.cs
Normal file
11
src/SQLInsight.Hub/Data/DesignTimeFactory.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace SQLInsight.Hub.Data;
|
||||
|
||||
/// <summary>Lets <c>dotnet ef migrations add</c> build the model without a database or configuration.</summary>
|
||||
internal sealed class DesignTimeFactory : IDesignTimeDbContextFactory<HubDbContext>
|
||||
{
|
||||
public HubDbContext CreateDbContext(string[] args) =>
|
||||
new(new DbContextOptionsBuilder<HubDbContext>().UseNpgsql("Host=design-time").UseSnakeCaseNamingConvention().Options);
|
||||
}
|
||||
120
src/SQLInsight.Hub/Data/Entities.cs
Normal file
120
src/SQLInsight.Hub/Data/Entities.cs
Normal file
|
|
@ -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<LicenseRecord> Licenses { get; } = [];
|
||||
public List<Instance> Instances { get; } = [];
|
||||
public List<SupportCase> 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; }
|
||||
|
||||
/// <summary>Set to stop a license from syncing before it expires (e.g. cancelled contract, leaked key).</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>When the newest applied state was collected; older submissions arriving late are ignored.</summary>
|
||||
public DateTime? LastCollectedAt { get; set; }
|
||||
|
||||
public DateTime? LastSubmissionAt { get; set; }
|
||||
public List<HubFinding> 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; }
|
||||
|
||||
/// <summary>0 Info … 4 Critical.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>The case number in the support system, e.g. "SUP-1234".</summary>
|
||||
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<HubFinding> Findings { get; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A vendor employee who may use the portal.</summary>
|
||||
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; }
|
||||
}
|
||||
68
src/SQLInsight.Hub/Data/HubDbContext.cs
Normal file
68
src/SQLInsight.Hub/Data/HubDbContext.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace SQLInsight.Hub.Data;
|
||||
|
||||
public sealed class HubDbContext(DbContextOptions<HubDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Customer> Customers => Set<Customer>();
|
||||
public DbSet<LicenseRecord> Licenses => Set<LicenseRecord>();
|
||||
public DbSet<Instance> Instances => Set<Instance>();
|
||||
public DbSet<Submission> Submissions => Set<Submission>();
|
||||
public DbSet<HubFinding> Findings => Set<HubFinding>();
|
||||
public DbSet<RuleState> RuleStates => Set<RuleState>();
|
||||
public DbSet<SupportCase> SupportCases => Set<SupportCase>();
|
||||
public DbSet<StaffUser> StaffUsers => Set<StaffUser>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Customer>(e =>
|
||||
{
|
||||
e.HasIndex(c => c.Name).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LicenseRecord>(e =>
|
||||
{
|
||||
e.HasKey(l => l.LicenseId);
|
||||
e.HasOne(l => l.Customer).WithMany(c => c.Licenses).HasForeignKey(l => l.CustomerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Instance>(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<Submission>(e =>
|
||||
{
|
||||
e.HasKey(s => s.SubmissionId);
|
||||
e.HasIndex(s => s.ReceivedAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HubFinding>(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<RuleState>(e =>
|
||||
{
|
||||
e.HasKey(r => new { r.InstanceId, r.RuleId });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SupportCase>(e =>
|
||||
{
|
||||
e.HasOne(c => c.Customer).WithMany(c => c.SupportCases).HasForeignKey(c => c.CustomerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<StaffUser>(e =>
|
||||
{
|
||||
e.HasIndex(u => u.UserName).IsUnique();
|
||||
});
|
||||
}
|
||||
}
|
||||
3
src/SQLInsight.Hub/Data/Migrations/.editorconfig
Normal file
3
src/SQLInsight.Hub/Data/Migrations/.editorconfig
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# EF Core migrations are generated; do not apply style or quality analyzers to them.
|
||||
[*.cs]
|
||||
generated_code = true
|
||||
488
src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.Designer.cs
generated
Normal file
488
src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("EvidenceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("evidence_json");
|
||||
|
||||
b.Property<string>("Explanation")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("explanation");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("first_seen_at");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("Recommendation")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("recommendation");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("RuleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("rule_id");
|
||||
|
||||
b.Property<int>("Severity")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<int?>("SupportCaseId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("support_case_id");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AgentVersion")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("agent_version");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<string>("Edition")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("edition");
|
||||
|
||||
b.Property<string>("InstanceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("instance_key");
|
||||
|
||||
b.Property<DateTime?>("LastCollectedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_collected_at");
|
||||
|
||||
b.Property<DateTime?>("LastSubmissionAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_submission_at");
|
||||
|
||||
b.Property<string>("ProductLevel")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("product_level");
|
||||
|
||||
b.Property<string>("ProductUpdateLevel")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("product_update_level");
|
||||
|
||||
b.Property<string>("ProductVersion")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("product_version");
|
||||
|
||||
b.Property<string>("Release")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("release");
|
||||
|
||||
b.Property<string>("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<string>("LicenseId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("license_id");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("first_seen_at");
|
||||
|
||||
b.Property<DateTime>("IssuedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("issued_at");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<int>("MaxInstances")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_instances");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("plan");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("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<int>("InstanceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<string>("RuleId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("rule_id");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("outcome");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<DateTime>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("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<Guid>("SubmissionId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("submission_id");
|
||||
|
||||
b.Property<bool>("Applied")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("applied");
|
||||
|
||||
b.Property<DateTime>("CollectedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("collected_at");
|
||||
|
||||
b.Property<int>("FindingCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("finding_count");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<DateTime>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("ClosedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("closed_at");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<bool>("IsOpen")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_open");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("note");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
273
src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.cs
Normal file
273
src/SQLInsight.Hub/Data/Migrations/20260929083733_Initial.cs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SQLInsight.Hub.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "customers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
name = table.Column<string>(type: "text", nullable: false),
|
||||
created_at = table.Column<DateTime>(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<int>(type: "integer", nullable: false),
|
||||
rule_id = table.Column<string>(type: "text", nullable: false),
|
||||
outcome = table.Column<string>(type: "text", nullable: false),
|
||||
reason = table.Column<string>(type: "text", nullable: true),
|
||||
updated_at = table.Column<DateTime>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
user_name = table.Column<string>(type: "text", nullable: false),
|
||||
display_name = table.Column<string>(type: "text", nullable: false),
|
||||
password_hash = table.Column<string>(type: "text", nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
last_login_at = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
instance_id = table.Column<int>(type: "integer", nullable: false),
|
||||
received_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
collected_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
finding_count = table.Column<int>(type: "integer", nullable: false),
|
||||
applied = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_submissions", x => x.submission_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "instances",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
customer_id = table.Column<int>(type: "integer", nullable: false),
|
||||
instance_key = table.Column<string>(type: "text", nullable: false),
|
||||
release = table.Column<string>(type: "text", nullable: true),
|
||||
product_version = table.Column<string>(type: "text", nullable: true),
|
||||
product_level = table.Column<string>(type: "text", nullable: true),
|
||||
product_update_level = table.Column<string>(type: "text", nullable: true),
|
||||
edition = table.Column<string>(type: "text", nullable: true),
|
||||
server_name = table.Column<string>(type: "text", nullable: true),
|
||||
agent_version = table.Column<string>(type: "text", nullable: true),
|
||||
last_collected_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_submission_at = table.Column<DateTime>(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<string>(type: "text", nullable: false),
|
||||
customer_id = table.Column<int>(type: "integer", nullable: false),
|
||||
plan = table.Column<string>(type: "text", nullable: false),
|
||||
issued_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
max_instances = table.Column<int>(type: "integer", nullable: false),
|
||||
first_seen_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
last_seen_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
revoked_reason = table.Column<string>(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<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
customer_id = table.Column<int>(type: "integer", nullable: false),
|
||||
reference = table.Column<string>(type: "text", nullable: false),
|
||||
title = table.Column<string>(type: "text", nullable: false),
|
||||
note = table.Column<string>(type: "text", nullable: true),
|
||||
is_open = table.Column<bool>(type: "boolean", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
closed_at = table.Column<DateTime>(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<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
instance_id = table.Column<int>(type: "integer", nullable: false),
|
||||
rule_id = table.Column<string>(type: "text", nullable: false),
|
||||
subject = table.Column<string>(type: "text", nullable: false),
|
||||
severity = table.Column<int>(type: "integer", nullable: false),
|
||||
title = table.Column<string>(type: "text", nullable: false),
|
||||
explanation = table.Column<string>(type: "text", nullable: false),
|
||||
recommendation = table.Column<string>(type: "text", nullable: false),
|
||||
evidence_json = table.Column<string>(type: "jsonb", nullable: false),
|
||||
first_seen_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
last_seen_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
resolved_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
support_case_id = table.Column<int>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
485
src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs
Normal file
485
src/SQLInsight.Hub/Data/Migrations/HubDbContextModelSnapshot.cs
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
// <auto-generated />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("EvidenceJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("evidence_json");
|
||||
|
||||
b.Property<string>("Explanation")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("explanation");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("first_seen_at");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<string>("Recommendation")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("recommendation");
|
||||
|
||||
b.Property<DateTime?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("resolved_at");
|
||||
|
||||
b.Property<string>("RuleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("rule_id");
|
||||
|
||||
b.Property<int>("Severity")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("severity");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<int?>("SupportCaseId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("support_case_id");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AgentVersion")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("agent_version");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<string>("Edition")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("edition");
|
||||
|
||||
b.Property<string>("InstanceKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("instance_key");
|
||||
|
||||
b.Property<DateTime?>("LastCollectedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_collected_at");
|
||||
|
||||
b.Property<DateTime?>("LastSubmissionAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_submission_at");
|
||||
|
||||
b.Property<string>("ProductLevel")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("product_level");
|
||||
|
||||
b.Property<string>("ProductUpdateLevel")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("product_update_level");
|
||||
|
||||
b.Property<string>("ProductVersion")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("product_version");
|
||||
|
||||
b.Property<string>("Release")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("release");
|
||||
|
||||
b.Property<string>("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<string>("LicenseId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("license_id");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at");
|
||||
|
||||
b.Property<DateTime>("FirstSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("first_seen_at");
|
||||
|
||||
b.Property<DateTime>("IssuedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("issued_at");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_seen_at");
|
||||
|
||||
b.Property<int>("MaxInstances")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("max_instances");
|
||||
|
||||
b.Property<string>("Plan")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("plan");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at");
|
||||
|
||||
b.Property<string>("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<int>("InstanceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<string>("RuleId")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("rule_id");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("outcome");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<DateTime>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<string>("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<Guid>("SubmissionId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("submission_id");
|
||||
|
||||
b.Property<bool>("Applied")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("applied");
|
||||
|
||||
b.Property<DateTime>("CollectedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("collected_at");
|
||||
|
||||
b.Property<int>("FindingCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("finding_count");
|
||||
|
||||
b.Property<int>("InstanceId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<DateTime>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime?>("ClosedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("closed_at");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("customer_id");
|
||||
|
||||
b.Property<bool>("IsOpen")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_open");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("note");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/SQLInsight.Hub/HubOptions.cs
Normal file
15
src/SQLInsight.Hub/HubOptions.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace SQLInsight.Hub;
|
||||
|
||||
public sealed class HubServerOptions
|
||||
{
|
||||
public const string SectionName = "SQLInsight:Hub";
|
||||
|
||||
/// <summary>Public keys trusted for licenses in addition to the embedded ones (key id → base64 SubjectPublicKeyInfo).</summary>
|
||||
public IDictionary<string, string> TrustedKeys { get; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>Largest accepted request body.</summary>
|
||||
public long MaxRequestBytes { get; set; } = 5 * 1024 * 1024;
|
||||
|
||||
/// <summary>Most findings accepted per submission.</summary>
|
||||
public int MaxFindingsPerSubmission { get; set; } = 2000;
|
||||
}
|
||||
|
|
@ -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();
|
||||
/// <summary>
|
||||
/// SQLInsight Hub: receives findings from paying customers' agents into PostgreSQL, and serves the vendor portal.
|
||||
/// </summary>
|
||||
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<HubServerOptions>(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<HubDbContext>(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<HubDbContext>().Database.MigrateAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,5 +4,15 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SQLInsight.Contracts\SQLInsight.Contracts.csproj" />
|
||||
<ProjectReference Include="..\SQLInsight.Licensing\SQLInsight.Licensing.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="EFCore.NamingConventions" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
150
tests/SQLInsight.IntegrationTests/HubApiTests.cs
Normal file
150
tests/SQLInsight.IntegrationTests/HubApiTests.cs
Normal file
|
|
@ -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<HubFixture>
|
||||
{
|
||||
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<HttpResponseMessage> 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<T> QueryAsync<T>(Func<HubDbContext, Task<T>> query)
|
||||
{
|
||||
await using var scope = hub.Factory.Services.CreateAsyncScope();
|
||||
return await query(scope.ServiceProvider.GetRequiredService<HubDbContext>());
|
||||
}
|
||||
|
||||
[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<SubmissionReceipt>())!.Duplicate);
|
||||
Assert.Equal(1, (await second.Content.ReadFromJsonAsync<SubmissionReceipt>())!.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<SubmissionReceipt>())!.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);
|
||||
}
|
||||
}
|
||||
94
tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
Normal file
94
tests/SQLInsight.IntegrationTests/HubEndToEndTests.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The whole paid path: agent monitors real SQL Server, queues its state, delivers it to the hub, hub stores it in PostgreSQL.
|
||||
/// </summary>
|
||||
[Collection(SqlServerTestGroup.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
public class HubEndToEndTests(SqlServerFixture sql, HubFixture hub) : IClassFixture<HubFixture>, 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<HttpClient> 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<string, TimeSpan>(), 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<HubSyncService>.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<HubDbContext>();
|
||||
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<HubSyncService>.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);
|
||||
}
|
||||
}
|
||||
56
tests/SQLInsight.IntegrationTests/HubFixture.cs
Normal file
56
tests/SQLInsight.IntegrationTests/HubFixture.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// SQLInsight Hub in-process on a real PostgreSQL, with a test signing key the hub trusts.
|
||||
/// </summary>
|
||||
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<HubServerOptions> 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<HubServerOptions>().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();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Testcontainers.MsSql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
<ProjectReference Include="..\..\src\SQLInsight.Agent\SQLInsight.Agent.csproj" />
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Web\SQLInsight.Web.csproj" />
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Notifications\SQLInsight.Notifications.csproj" />
|
||||
<ProjectReference Include="..\..\src\SQLInsight.Hub\SQLInsight.Hub.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\db\permissions\grant-least-privilege.sql" Link="Scripts\grant-least-privilege.sql" CopyToOutputDirectory="PreserveNewest" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue