wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -4,6 +4,7 @@ using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Export;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexHub.WebService.Models;
|
||||
using StellaOps.VexHub.WebService.Security;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -21,7 +22,8 @@ public static class VexHubEndpointExtensions
|
||||
public static WebApplication MapVexHubEndpoints(this WebApplication app)
|
||||
{
|
||||
var vexGroup = app.MapGroup("/api/v1/vex")
|
||||
.WithTags("VEX");
|
||||
.WithTags("VEX")
|
||||
.RequireAuthorization(VexHubPolicies.Read);
|
||||
|
||||
// GET /api/v1/vex/cve/{cve-id}
|
||||
vexGroup.MapGet("/cve/{cveId}", GetByCve)
|
||||
|
||||
@@ -6,6 +6,7 @@ using StellaOps.VexHub.Core.Extensions;
|
||||
using StellaOps.VexHub.Persistence.Extensions;
|
||||
using StellaOps.VexHub.WebService.Extensions;
|
||||
using StellaOps.VexHub.WebService.Middleware;
|
||||
using StellaOps.VexHub.WebService.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -43,7 +44,13 @@ builder.Services.AddAuthentication("ApiKey")
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
// VexHub uses API-key authentication; policies require an authenticated API key holder.
|
||||
// Scope enforcement is delegated to the API key configuration (per-key scope list).
|
||||
options.AddPolicy(VexHubPolicies.Read, policy => policy.RequireAuthenticatedUser());
|
||||
options.AddPolicy(VexHubPolicies.Admin, policy => policy.RequireAuthenticatedUser());
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
|
||||
namespace StellaOps.VexHub.WebService.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Named authorization policy constants for the VexHub service.
|
||||
/// VexHub uses API-key authentication. All VEX query endpoints require a valid,
|
||||
/// authenticated API key. Scope enforcement is delegated to the API key configuration.
|
||||
/// </summary>
|
||||
internal static class VexHubPolicies
|
||||
{
|
||||
/// <summary>Policy for querying and reading VEX statements. Requires an authenticated API key.</summary>
|
||||
public const string Read = "VexHub.Read";
|
||||
|
||||
/// <summary>Policy for administrative operations (ingestion, source management). Requires an authenticated API key with admin scope.</summary>
|
||||
public const string Admin = "VexHub.Admin";
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# VexHub Compiled Models
|
||||
|
||||
This directory contains compiled model stubs for the VexHub EF Core DbContext.
|
||||
|
||||
## Regeneration
|
||||
|
||||
To regenerate the compiled model from a provisioned database:
|
||||
|
||||
```bash
|
||||
dotnet ef dbcontext optimize \
|
||||
--project src/VexHub/__Libraries/StellaOps.VexHub.Persistence/ \
|
||||
--output-dir EfCore/CompiledModels \
|
||||
--namespace StellaOps.VexHub.Persistence.EfCore.CompiledModels
|
||||
```
|
||||
|
||||
**Prerequisites:**
|
||||
- A PostgreSQL instance with the `vexhub` schema provisioned (run `001_initial_schema.sql`).
|
||||
- Set `STELLAOPS_VEXHUB_EF_CONNECTION` environment variable or use the default dev connection.
|
||||
|
||||
## Current State
|
||||
|
||||
The files in this directory are placeholder stubs. The runtime factory (`VexHubDbContextFactory`)
|
||||
will fall back to reflection-based model building until a full compiled model is generated.
|
||||
Once generated, the `UseModel(VexHubDbContextModel.Instance)` path will use the static compiled
|
||||
model for the default `vexhub` schema, improving startup time.
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for VexHubDbContext.
|
||||
/// This is a placeholder that delegates to runtime model building.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
[DbContext(typeof(Context.VexHubDbContext))]
|
||||
public partial class VexHubDbContextModel : RuntimeModel
|
||||
{
|
||||
private static VexHubDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new VexHubDbContextModel();
|
||||
_instance.Initialize();
|
||||
_instance.Customize();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for VexHubDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class VexHubDbContextModel
|
||||
{
|
||||
partial void Initialize()
|
||||
{
|
||||
// Stub: when a real compiled model is generated, entity types will be registered here.
|
||||
// The runtime factory will fall back to reflection-based model building for all schemas
|
||||
// until this stub is replaced with a full compiled model.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Context;
|
||||
|
||||
public partial class VexHubDbContext
|
||||
{
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder)
|
||||
{
|
||||
// ── FK: statements.source_id -> sources.source_id ──
|
||||
modelBuilder.Entity<VexStatement>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Source)
|
||||
.WithMany(s => s.Statements)
|
||||
.HasForeignKey(e => e.SourceId)
|
||||
.HasPrincipalKey(s => s.SourceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ── FK: provenance.statement_id -> statements.id (ON DELETE CASCADE) ──
|
||||
modelBuilder.Entity<VexProvenance>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Statement)
|
||||
.WithOne(s => s.Provenance)
|
||||
.HasForeignKey<VexProvenance>(e => e.StatementId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ── FK: conflicts.winning_statement_id -> statements.id ──
|
||||
modelBuilder.Entity<VexConflict>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.WinningStatement)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WinningStatementId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── FK: ingestion_jobs.source_id -> sources.source_id ──
|
||||
modelBuilder.Entity<VexIngestionJob>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.Source)
|
||||
.WithMany(s => s.IngestionJobs)
|
||||
.HasForeignKey(e => e.SourceId)
|
||||
.HasPrincipalKey(s => s.SourceId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,261 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for VexHub module.
|
||||
/// This is a stub that will be scaffolded from the PostgreSQL database.
|
||||
/// EF Core DbContext for the VexHub module.
|
||||
/// Maps to the vexhub PostgreSQL schema: sources, statements, conflicts, provenance,
|
||||
/// ingestion_jobs, and webhook_subscriptions tables.
|
||||
/// </summary>
|
||||
public class VexHubDbContext : DbContext
|
||||
public partial class VexHubDbContext : DbContext
|
||||
{
|
||||
public VexHubDbContext(DbContextOptions<VexHubDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public VexHubDbContext(DbContextOptions<VexHubDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "vexhub"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<VexSource> Sources { get; set; }
|
||||
public virtual DbSet<VexStatement> Statements { get; set; }
|
||||
public virtual DbSet<VexConflict> Conflicts { get; set; }
|
||||
public virtual DbSet<VexProvenance> Provenances { get; set; }
|
||||
public virtual DbSet<VexIngestionJob> IngestionJobs { get; set; }
|
||||
public virtual DbSet<VexWebhookSubscription> WebhookSubscriptions { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema("vexhub");
|
||||
base.OnModelCreating(modelBuilder);
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// ── sources ──────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<VexSource>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.SourceId).HasName("sources_pkey");
|
||||
entity.ToTable("sources", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.IsEnabled, e.LastPolledAt }, "idx_sources_enabled");
|
||||
entity.HasIndex(e => e.SourceFormat, "idx_sources_format");
|
||||
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.SourceUri).HasColumnName("source_uri");
|
||||
entity.Property(e => e.SourceFormat).HasColumnName("source_format");
|
||||
entity.Property(e => e.IssuerCategory).HasColumnName("issuer_category");
|
||||
entity.Property(e => e.TrustTier)
|
||||
.HasDefaultValueSql("'UNKNOWN'")
|
||||
.HasColumnName("trust_tier");
|
||||
entity.Property(e => e.IsEnabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_enabled");
|
||||
entity.Property(e => e.PollingIntervalSeconds).HasColumnName("polling_interval_seconds");
|
||||
entity.Property(e => e.LastPolledAt).HasColumnName("last_polled_at");
|
||||
entity.Property(e => e.LastErrorMessage).HasColumnName("last_error_message");
|
||||
entity.Property(e => e.Config)
|
||||
.HasDefaultValueSql("'{}'::jsonb")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("config");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
// ── statements ───────────────────────────────────────────────────
|
||||
modelBuilder.Entity<VexStatement>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("statements_pkey");
|
||||
entity.ToTable("statements", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.VulnerabilityId, "idx_statements_vulnerability");
|
||||
entity.HasIndex(e => e.ProductKey, "idx_statements_product");
|
||||
entity.HasIndex(e => e.SourceId, "idx_statements_source");
|
||||
entity.HasIndex(e => e.Status, "idx_statements_status");
|
||||
entity.HasIndex(e => e.VerificationStatus, "idx_statements_verification");
|
||||
entity.HasIndex(e => e.IngestedAt, "idx_statements_ingested");
|
||||
entity.HasIndex(e => e.ContentDigest, "idx_statements_digest");
|
||||
entity.HasIndex(e => e.IsFlagged, "idx_statements_flagged")
|
||||
.HasFilter("(is_flagged = true)");
|
||||
entity.HasIndex(e => new { e.VulnerabilityId, e.ProductKey }, "idx_statements_vuln_product");
|
||||
|
||||
// Unique constraint for UPSERT conflict target
|
||||
entity.HasAlternateKey(e => new { e.SourceId, e.SourceStatementId, e.VulnerabilityId, e.ProductKey })
|
||||
.HasName("statements_source_id_source_statement_id_vulnerability_id_p_key");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.SourceStatementId).HasColumnName("source_statement_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.SourceDocumentId).HasColumnName("source_document_id");
|
||||
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
|
||||
entity.Property(e => e.VulnerabilityAliases)
|
||||
.HasDefaultValueSql("'{}'::text[]")
|
||||
.HasColumnName("vulnerability_aliases");
|
||||
entity.Property(e => e.ProductKey).HasColumnName("product_key");
|
||||
entity.Property(e => e.Status).HasColumnName("status");
|
||||
entity.Property(e => e.Justification).HasColumnName("justification");
|
||||
entity.Property(e => e.StatusNotes).HasColumnName("status_notes");
|
||||
entity.Property(e => e.ImpactStatement).HasColumnName("impact_statement");
|
||||
entity.Property(e => e.ActionStatement).HasColumnName("action_statement");
|
||||
entity.Property(e => e.Versions)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("versions");
|
||||
entity.Property(e => e.IssuedAt).HasColumnName("issued_at");
|
||||
entity.Property(e => e.SourceUpdatedAt).HasColumnName("source_updated_at");
|
||||
entity.Property(e => e.VerificationStatus)
|
||||
.HasDefaultValueSql("'none'")
|
||||
.HasColumnName("verification_status");
|
||||
entity.Property(e => e.VerifiedAt).HasColumnName("verified_at");
|
||||
entity.Property(e => e.SigningKeyFingerprint).HasColumnName("signing_key_fingerprint");
|
||||
entity.Property(e => e.IsFlagged)
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_flagged");
|
||||
entity.Property(e => e.FlagReason).HasColumnName("flag_reason");
|
||||
entity.Property(e => e.IngestedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("ingested_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
entity.Property(e => e.ContentDigest).HasColumnName("content_digest");
|
||||
|
||||
// search_vector is maintained by a DB trigger; EF should not write to it
|
||||
entity.Property(e => e.SearchVector)
|
||||
.HasColumnType("tsvector")
|
||||
.HasColumnName("search_vector");
|
||||
});
|
||||
|
||||
// ── conflicts ────────────────────────────────────────────────────
|
||||
modelBuilder.Entity<VexConflict>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("conflicts_pkey");
|
||||
entity.ToTable("conflicts", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.VulnerabilityId, e.ProductKey }, "idx_conflicts_vuln_product");
|
||||
entity.HasIndex(e => e.ResolutionStatus, "idx_conflicts_status");
|
||||
entity.HasIndex(e => e.Severity, "idx_conflicts_severity");
|
||||
entity.HasIndex(e => e.DetectedAt, "idx_conflicts_detected");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.VulnerabilityId).HasColumnName("vulnerability_id");
|
||||
entity.Property(e => e.ProductKey).HasColumnName("product_key");
|
||||
entity.Property(e => e.ConflictingStatementIds).HasColumnName("conflicting_statement_ids");
|
||||
entity.Property(e => e.Severity).HasColumnName("severity");
|
||||
entity.Property(e => e.Description).HasColumnName("description");
|
||||
entity.Property(e => e.ResolutionStatus)
|
||||
.HasDefaultValueSql("'open'")
|
||||
.HasColumnName("resolution_status");
|
||||
entity.Property(e => e.ResolutionMethod).HasColumnName("resolution_method");
|
||||
entity.Property(e => e.WinningStatementId).HasColumnName("winning_statement_id");
|
||||
entity.Property(e => e.DetectedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("detected_at");
|
||||
entity.Property(e => e.ResolvedAt).HasColumnName("resolved_at");
|
||||
});
|
||||
|
||||
// ── provenance ───────────────────────────────────────────────────
|
||||
modelBuilder.Entity<VexProvenance>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.StatementId).HasName("provenance_pkey");
|
||||
entity.ToTable("provenance", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SourceId, "idx_provenance_source");
|
||||
entity.HasIndex(e => e.IssuerId, "idx_provenance_issuer");
|
||||
|
||||
entity.Property(e => e.StatementId).HasColumnName("statement_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.DocumentUri).HasColumnName("document_uri");
|
||||
entity.Property(e => e.DocumentDigest).HasColumnName("document_digest");
|
||||
entity.Property(e => e.SourceRevision).HasColumnName("source_revision");
|
||||
entity.Property(e => e.IssuerId).HasColumnName("issuer_id");
|
||||
entity.Property(e => e.IssuerName).HasColumnName("issuer_name");
|
||||
entity.Property(e => e.FetchedAt).HasColumnName("fetched_at");
|
||||
entity.Property(e => e.TransformationRules).HasColumnName("transformation_rules");
|
||||
entity.Property(e => e.RawStatementJson)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("raw_statement_json");
|
||||
});
|
||||
|
||||
// ── ingestion_jobs ───────────────────────────────────────────────
|
||||
modelBuilder.Entity<VexIngestionJob>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.JobId).HasName("ingestion_jobs_pkey");
|
||||
entity.ToTable("ingestion_jobs", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.SourceId, "idx_jobs_source");
|
||||
entity.HasIndex(e => e.Status, "idx_jobs_status");
|
||||
entity.HasIndex(e => e.StartedAt, "idx_jobs_started")
|
||||
.IsDescending(true);
|
||||
|
||||
entity.Property(e => e.JobId)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("job_id");
|
||||
entity.Property(e => e.SourceId).HasColumnName("source_id");
|
||||
entity.Property(e => e.Status)
|
||||
.HasDefaultValueSql("'queued'")
|
||||
.HasColumnName("status");
|
||||
entity.Property(e => e.StartedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("started_at");
|
||||
entity.Property(e => e.CompletedAt).HasColumnName("completed_at");
|
||||
entity.Property(e => e.DocumentsProcessed)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("documents_processed");
|
||||
entity.Property(e => e.StatementsIngested)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("statements_ingested");
|
||||
entity.Property(e => e.StatementsDeduplicated)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("statements_deduplicated");
|
||||
entity.Property(e => e.ConflictsDetected)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("conflicts_detected");
|
||||
entity.Property(e => e.ErrorCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("error_count");
|
||||
entity.Property(e => e.ErrorMessage).HasColumnName("error_message");
|
||||
entity.Property(e => e.Checkpoint).HasColumnName("checkpoint");
|
||||
});
|
||||
|
||||
// ── webhook_subscriptions ────────────────────────────────────────
|
||||
modelBuilder.Entity<VexWebhookSubscription>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("webhook_subscriptions_pkey");
|
||||
entity.ToTable("webhook_subscriptions", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.IsEnabled, "idx_webhooks_enabled");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasDefaultValueSql("gen_random_uuid()")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.Name).HasColumnName("name");
|
||||
entity.Property(e => e.CallbackUrl).HasColumnName("callback_url");
|
||||
entity.Property(e => e.Secret).HasColumnName("secret");
|
||||
entity.Property(e => e.EventTypes)
|
||||
.HasDefaultValueSql("'{}'::text[]")
|
||||
.HasColumnName("event_types");
|
||||
entity.Property(e => e.FilterVulnerabilityIds).HasColumnName("filter_vulnerability_ids");
|
||||
entity.Property(e => e.FilterProductKeys).HasColumnName("filter_product_keys");
|
||||
entity.Property(e => e.FilterSources).HasColumnName("filter_sources");
|
||||
entity.Property(e => e.IsEnabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_enabled");
|
||||
entity.Property(e => e.LastTriggeredAt).HasColumnName("last_triggered_at");
|
||||
entity.Property(e => e.FailureCount)
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("failure_count");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Context;
|
||||
|
||||
public sealed class VexHubDesignTimeDbContextFactory : IDesignTimeDbContextFactory<VexHubDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=vexhub,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_VEXHUB_EF_CONNECTION";
|
||||
|
||||
public VexHubDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<VexHubDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new VexHubDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
public partial class VexConflict
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: the winning statement if auto-resolved.
|
||||
/// </summary>
|
||||
public virtual VexStatement? WinningStatement { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexhub.conflicts table.
|
||||
/// </summary>
|
||||
public partial class VexConflict
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string VulnerabilityId { get; set; } = null!;
|
||||
public string ProductKey { get; set; } = null!;
|
||||
public Guid[] ConflictingStatementIds { get; set; } = null!;
|
||||
public string Severity { get; set; } = null!;
|
||||
public string Description { get; set; } = null!;
|
||||
public string ResolutionStatus { get; set; } = null!;
|
||||
public string? ResolutionMethod { get; set; }
|
||||
public Guid? WinningStatementId { get; set; }
|
||||
public DateTime DetectedAt { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
public partial class VexIngestionJob
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: the source being ingested.
|
||||
/// </summary>
|
||||
public virtual VexSource? Source { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexhub.ingestion_jobs table.
|
||||
/// </summary>
|
||||
public partial class VexIngestionJob
|
||||
{
|
||||
public Guid JobId { get; set; }
|
||||
public string SourceId { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public DateTime StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public int DocumentsProcessed { get; set; }
|
||||
public int StatementsIngested { get; set; }
|
||||
public int StatementsDeduplicated { get; set; }
|
||||
public int ConflictsDetected { get; set; }
|
||||
public int ErrorCount { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string? Checkpoint { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
public partial class VexProvenance
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: the statement this provenance belongs to (1:1).
|
||||
/// </summary>
|
||||
public virtual VexStatement? Statement { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexhub.provenance table.
|
||||
/// </summary>
|
||||
public partial class VexProvenance
|
||||
{
|
||||
public Guid StatementId { get; set; }
|
||||
public string SourceId { get; set; } = null!;
|
||||
public string? DocumentUri { get; set; }
|
||||
public string? DocumentDigest { get; set; }
|
||||
public string? SourceRevision { get; set; }
|
||||
public string? IssuerId { get; set; }
|
||||
public string? IssuerName { get; set; }
|
||||
public DateTime FetchedAt { get; set; }
|
||||
public string[]? TransformationRules { get; set; }
|
||||
public string? RawStatementJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
public partial class VexSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: statements from this source.
|
||||
/// </summary>
|
||||
public virtual ICollection<VexStatement> Statements { get; set; } = new List<VexStatement>();
|
||||
|
||||
/// <summary>
|
||||
/// Navigation: ingestion jobs for this source.
|
||||
/// </summary>
|
||||
public virtual ICollection<VexIngestionJob> IngestionJobs { get; set; } = new List<VexIngestionJob>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexhub.sources table.
|
||||
/// </summary>
|
||||
public partial class VexSource
|
||||
{
|
||||
public string SourceId { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string? SourceUri { get; set; }
|
||||
public string SourceFormat { get; set; } = null!;
|
||||
public string? IssuerCategory { get; set; }
|
||||
public string TrustTier { get; set; } = null!;
|
||||
public bool IsEnabled { get; set; }
|
||||
public int? PollingIntervalSeconds { get; set; }
|
||||
public DateTime? LastPolledAt { get; set; }
|
||||
public string? LastErrorMessage { get; set; }
|
||||
public string Config { get; set; } = null!;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
public partial class VexStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation: the source that provided this statement.
|
||||
/// </summary>
|
||||
public virtual VexSource? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Navigation: provenance for this statement (1:1).
|
||||
/// </summary>
|
||||
public virtual VexProvenance? Provenance { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexhub.statements table.
|
||||
/// </summary>
|
||||
public partial class VexStatement
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string SourceStatementId { get; set; } = null!;
|
||||
public string SourceId { get; set; } = null!;
|
||||
public string SourceDocumentId { get; set; } = null!;
|
||||
public string VulnerabilityId { get; set; } = null!;
|
||||
public string[]? VulnerabilityAliases { get; set; }
|
||||
public string ProductKey { get; set; } = null!;
|
||||
public string Status { get; set; } = null!;
|
||||
public string? Justification { get; set; }
|
||||
public string? StatusNotes { get; set; }
|
||||
public string? ImpactStatement { get; set; }
|
||||
public string? ActionStatement { get; set; }
|
||||
public string? Versions { get; set; }
|
||||
public DateTime? IssuedAt { get; set; }
|
||||
public DateTime? SourceUpdatedAt { get; set; }
|
||||
public string VerificationStatus { get; set; } = null!;
|
||||
public DateTime? VerifiedAt { get; set; }
|
||||
public string? SigningKeyFingerprint { get; set; }
|
||||
public bool IsFlagged { get; set; }
|
||||
public string? FlagReason { get; set; }
|
||||
public DateTime IngestedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public string ContentDigest { get; set; } = null!;
|
||||
public string? SearchVector { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity for vexhub.webhook_subscriptions table.
|
||||
/// </summary>
|
||||
public partial class VexWebhookSubscription
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string CallbackUrl { get; set; } = null!;
|
||||
public string? Secret { get; set; }
|
||||
public string[] EventTypes { get; set; } = null!;
|
||||
public string[]? FilterVulnerabilityIds { get; set; }
|
||||
public string[]? FilterProductKeys { get; set; }
|
||||
public string[]? FilterSources { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
public DateTime? LastTriggeredAt { get; set; }
|
||||
public int FailureCount { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexHub.Persistence.Postgres.Models;
|
||||
using System.Text.Json;
|
||||
using EfModels = StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the VEX provenance repository.
|
||||
/// PostgreSQL (EF Core) implementation of the VEX provenance repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly VexHubDataSource _dataSource;
|
||||
private readonly ILogger<PostgresVexProvenanceRepository> _logger;
|
||||
|
||||
@@ -28,15 +29,22 @@ public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
VexProvenance provenance,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
// The UPSERT has a single-column PK conflict (statement_id).
|
||||
// Using ExecuteSqlRawAsync for the ON CONFLICT DO UPDATE pattern.
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(provenance);
|
||||
|
||||
var sql = """
|
||||
INSERT INTO vexhub.provenance (
|
||||
statement_id, source_id, document_uri, document_digest,
|
||||
source_revision, issuer_id, issuer_name, fetched_at,
|
||||
transformation_rules, raw_statement_json
|
||||
) VALUES (
|
||||
@StatementId, @SourceId, @DocumentUri, @DocumentDigest,
|
||||
@SourceRevision, @IssuerId, @IssuerName, @FetchedAt,
|
||||
@TransformationRules, @RawStatementJson::jsonb
|
||||
{0}, {1}, {2}, {3},
|
||||
{4}, {5}, {6}, {7},
|
||||
{8}, {9}::jsonb
|
||||
)
|
||||
ON CONFLICT (statement_id) DO UPDATE SET
|
||||
document_uri = EXCLUDED.document_uri,
|
||||
@@ -49,9 +57,14 @@ public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
RETURNING statement_id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entity = ToEntity(provenance);
|
||||
await connection.ExecuteScalarAsync<Guid>(sql, entity);
|
||||
await dbContext.Database.SqlQueryRaw<Guid>(
|
||||
sql,
|
||||
entity.StatementId, entity.SourceId,
|
||||
(object?)entity.DocumentUri ?? DBNull.Value, (object?)entity.DocumentDigest ?? DBNull.Value,
|
||||
(object?)entity.SourceRevision ?? DBNull.Value, (object?)entity.IssuerId ?? DBNull.Value,
|
||||
(object?)entity.IssuerName ?? DBNull.Value, entity.FetchedAt,
|
||||
(object?)entity.TransformationRules ?? DBNull.Value, (object?)entity.RawStatementJson ?? DBNull.Value
|
||||
).FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
_logger.LogDebug("Added provenance for statement {StatementId}", provenance.StatementId);
|
||||
return provenance;
|
||||
@@ -61,11 +74,12 @@ public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
Guid statementId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM vexhub.provenance WHERE statement_id = @StatementId";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entity = await connection.QueryFirstOrDefaultAsync<VexProvenanceEntity>(
|
||||
sql, new { StatementId = statementId });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Provenances
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.StatementId == statementId, cancellationToken);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
@@ -87,15 +101,17 @@ public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
Guid statementId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM vexhub.provenance WHERE statement_id = @StatementId";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var affected = await connection.ExecuteAsync(sql, new { StatementId = statementId });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var affected = await dbContext.Provenances
|
||||
.Where(p => p.StatementId == statementId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
private static VexProvenanceEntity ToEntity(VexProvenance model) => new()
|
||||
private static EfModels.VexProvenance ToEntity(VexProvenance model) => new()
|
||||
{
|
||||
StatementId = model.StatementId,
|
||||
SourceId = model.SourceId,
|
||||
@@ -104,12 +120,12 @@ public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
SourceRevision = model.SourceRevision,
|
||||
IssuerId = model.IssuerId,
|
||||
IssuerName = model.IssuerName,
|
||||
FetchedAt = model.FetchedAt,
|
||||
FetchedAt = model.FetchedAt.UtcDateTime,
|
||||
TransformationRules = model.TransformationRules?.ToArray(),
|
||||
RawStatementJson = model.RawStatementJson
|
||||
};
|
||||
|
||||
private static VexProvenance ToModel(VexProvenanceEntity entity) => new()
|
||||
private static VexProvenance ToModel(EfModels.VexProvenance entity) => new()
|
||||
{
|
||||
StatementId = entity.StatementId,
|
||||
SourceId = entity.SourceId,
|
||||
@@ -118,8 +134,10 @@ public sealed class PostgresVexProvenanceRepository : IVexProvenanceRepository
|
||||
SourceRevision = entity.SourceRevision,
|
||||
IssuerId = entity.IssuerId,
|
||||
IssuerName = entity.IssuerName,
|
||||
FetchedAt = entity.FetchedAt,
|
||||
FetchedAt = new DateTimeOffset(entity.FetchedAt, TimeSpan.Zero),
|
||||
TransformationRules = entity.TransformationRules?.ToList(),
|
||||
RawStatementJson = entity.RawStatementJson
|
||||
};
|
||||
|
||||
private string GetSchemaName() => VexHubDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.VexHub.Core;
|
||||
using StellaOps.VexHub.Core.Models;
|
||||
using StellaOps.VexHub.Persistence.Postgres.Models;
|
||||
using StellaOps.VexHub.Persistence.EfCore.Models;
|
||||
using StellaOps.VexLens.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the VEX statement repository.
|
||||
/// PostgreSQL (EF Core) implementation of the VEX statement repository.
|
||||
/// </summary>
|
||||
public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
{
|
||||
private const int CommandTimeoutSeconds = 30;
|
||||
|
||||
private readonly VexHubDataSource _dataSource;
|
||||
private readonly ILogger<PostgresVexStatementRepository> _logger;
|
||||
|
||||
@@ -29,7 +31,14 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
AggregatedVexStatement statement,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
// The UPSERT has a multi-column conflict clause (source_id, source_statement_id, vulnerability_id, product_key).
|
||||
// Using ExecuteSqlRawAsync for the complex ON CONFLICT DO UPDATE pattern per cutover strategy guidance.
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = ToEntity(statement);
|
||||
|
||||
var sql = """
|
||||
INSERT INTO vexhub.statements (
|
||||
id, source_statement_id, source_id, source_document_id, vulnerability_id,
|
||||
vulnerability_aliases, product_key, status, justification, status_notes,
|
||||
@@ -37,11 +46,11 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
verification_status, verified_at, signing_key_fingerprint, is_flagged, flag_reason,
|
||||
ingested_at, content_digest
|
||||
) VALUES (
|
||||
@Id, @SourceStatementId, @SourceId, @SourceDocumentId, @VulnerabilityId,
|
||||
@VulnerabilityAliases, @ProductKey, @Status, @Justification, @StatusNotes,
|
||||
@ImpactStatement, @ActionStatement, @Versions::jsonb, @IssuedAt, @SourceUpdatedAt,
|
||||
@VerificationStatus, @VerifiedAt, @SigningKeyFingerprint, @IsFlagged, @FlagReason,
|
||||
@IngestedAt, @ContentDigest
|
||||
{0}, {1}, {2}, {3}, {4},
|
||||
{5}, {6}, {7}, {8}, {9},
|
||||
{10}, {11}, {12}::jsonb, {13}, {14},
|
||||
{15}, {16}, {17}, {18}, {19},
|
||||
{20}, {21}
|
||||
)
|
||||
ON CONFLICT (source_id, source_statement_id, vulnerability_id, product_key)
|
||||
DO UPDATE SET
|
||||
@@ -62,11 +71,22 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entity = ToEntity(statement);
|
||||
var id = await connection.ExecuteScalarAsync<Guid>(sql, entity);
|
||||
// Use FormattableString to parameterize the raw SQL
|
||||
var returnedId = await dbContext.Database.SqlQueryRaw<Guid>(
|
||||
sql,
|
||||
entity.Id, entity.SourceStatementId, entity.SourceId, entity.SourceDocumentId, entity.VulnerabilityId,
|
||||
(object?)entity.VulnerabilityAliases ?? DBNull.Value, entity.ProductKey, entity.Status,
|
||||
(object?)entity.Justification ?? DBNull.Value, (object?)entity.StatusNotes ?? DBNull.Value,
|
||||
(object?)entity.ImpactStatement ?? DBNull.Value, (object?)entity.ActionStatement ?? DBNull.Value,
|
||||
(object?)entity.Versions ?? DBNull.Value, (object?)entity.IssuedAt ?? DBNull.Value,
|
||||
(object?)entity.SourceUpdatedAt ?? DBNull.Value,
|
||||
entity.VerificationStatus, (object?)entity.VerifiedAt ?? DBNull.Value,
|
||||
(object?)entity.SigningKeyFingerprint ?? DBNull.Value, entity.IsFlagged,
|
||||
(object?)entity.FlagReason ?? DBNull.Value,
|
||||
entity.IngestedAt, entity.ContentDigest
|
||||
).FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return statement with { Id = id };
|
||||
return statement with { Id = returnedId };
|
||||
}
|
||||
|
||||
public async Task<int> BulkUpsertAsync(
|
||||
@@ -86,10 +106,12 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
Guid id,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM vexhub.statements WHERE id = @Id";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entity = await connection.QueryFirstOrDefaultAsync<VexStatementEntity>(sql, new { Id = id });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Statements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
|
||||
|
||||
return entity is null ? null : ToModel(entity);
|
||||
}
|
||||
@@ -100,20 +122,28 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
// The ANY(vulnerability_aliases) check requires raw SQL because EF Core's LINQ
|
||||
// translation for PostgreSQL array containment is limited for this pattern.
|
||||
var sql = """
|
||||
SELECT * FROM vexhub.statements
|
||||
WHERE vulnerability_id = @CveId OR @CveId = ANY(vulnerability_aliases)
|
||||
WHERE vulnerability_id = {0} OR {0} = ANY(vulnerability_aliases)
|
||||
ORDER BY ingested_at DESC
|
||||
""";
|
||||
|
||||
if (limit.HasValue)
|
||||
sql += " LIMIT @Limit";
|
||||
if (offset.HasValue)
|
||||
sql += " OFFSET @Offset";
|
||||
if (limit.HasValue && offset.HasValue)
|
||||
sql += $" LIMIT {limit.Value} OFFSET {offset.Value}";
|
||||
else if (limit.HasValue)
|
||||
sql += $" LIMIT {limit.Value}";
|
||||
else if (offset.HasValue)
|
||||
sql += $" OFFSET {offset.Value}";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entities = await connection.QueryAsync<VexStatementEntity>(
|
||||
sql, new { CveId = cveId, Limit = limit, Offset = offset });
|
||||
var entities = await dbContext.Statements
|
||||
.FromSqlRaw(sql, cveId)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
@@ -124,21 +154,20 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM vexhub.statements
|
||||
WHERE product_key = @Purl
|
||||
ORDER BY ingested_at DESC
|
||||
""";
|
||||
|
||||
if (limit.HasValue)
|
||||
sql += " LIMIT @Limit";
|
||||
if (offset.HasValue)
|
||||
sql += " OFFSET @Offset";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entities = await connection.QueryAsync<VexStatementEntity>(
|
||||
sql, new { Purl = purl, Limit = limit, Offset = offset });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<VexStatement> query = dbContext.Statements
|
||||
.AsNoTracking()
|
||||
.Where(s => s.ProductKey == purl)
|
||||
.OrderByDescending(s => s.IngestedAt);
|
||||
|
||||
if (offset.HasValue)
|
||||
query = query.Skip(offset.Value);
|
||||
if (limit.HasValue)
|
||||
query = query.Take(limit.Value);
|
||||
|
||||
var entities = await query.ToListAsync(cancellationToken);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
@@ -148,21 +177,20 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = """
|
||||
SELECT * FROM vexhub.statements
|
||||
WHERE source_id = @SourceId
|
||||
ORDER BY ingested_at DESC
|
||||
""";
|
||||
|
||||
if (limit.HasValue)
|
||||
sql += " LIMIT @Limit";
|
||||
if (offset.HasValue)
|
||||
sql += " OFFSET @Offset";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entities = await connection.QueryAsync<VexStatementEntity>(
|
||||
sql, new { SourceId = sourceId, Limit = limit, Offset = offset });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<VexStatement> query = dbContext.Statements
|
||||
.AsNoTracking()
|
||||
.Where(s => s.SourceId == sourceId)
|
||||
.OrderByDescending(s => s.IngestedAt);
|
||||
|
||||
if (offset.HasValue)
|
||||
query = query.Skip(offset.Value);
|
||||
if (limit.HasValue)
|
||||
query = query.Take(limit.Value);
|
||||
|
||||
var entities = await query.ToListAsync(cancellationToken);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
@@ -170,24 +198,27 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
string contentDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT EXISTS(SELECT 1 FROM vexhub.statements WHERE content_digest = @Digest)";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
return await connection.ExecuteScalarAsync<bool>(sql, new { Digest = contentDigest });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await dbContext.Statements
|
||||
.AsNoTracking()
|
||||
.AnyAsync(s => s.ContentDigest == contentDigest, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<long> GetCountAsync(
|
||||
VexStatementFilter? filter = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT COUNT(*) FROM vexhub.statements WHERE 1=1";
|
||||
var parameters = new DynamicParameters();
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<VexStatement> query = dbContext.Statements.AsNoTracking();
|
||||
|
||||
if (filter is not null)
|
||||
sql = ApplyFilter(sql, filter, parameters);
|
||||
query = ApplyFilter(query, filter);
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
return await connection.ExecuteScalarAsync<long>(sql, parameters);
|
||||
return await query.LongCountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AggregatedVexStatement>> SearchAsync(
|
||||
@@ -196,26 +227,20 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
int? offset = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = "SELECT * FROM vexhub.statements WHERE 1=1";
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
sql = ApplyFilter(sql, filter, parameters);
|
||||
sql += " ORDER BY ingested_at DESC";
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
sql += " LIMIT @Limit";
|
||||
parameters.Add("Limit", limit);
|
||||
}
|
||||
if (offset.HasValue)
|
||||
{
|
||||
sql += " OFFSET @Offset";
|
||||
parameters.Add("Offset", offset);
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
var entities = await connection.QueryAsync<VexStatementEntity>(sql, parameters);
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
IQueryable<VexStatement> query = dbContext.Statements.AsNoTracking();
|
||||
|
||||
query = ApplyFilter(query, filter);
|
||||
query = query.OrderByDescending(s => s.IngestedAt);
|
||||
|
||||
if (offset.HasValue)
|
||||
query = query.Skip(offset.Value);
|
||||
if (limit.HasValue)
|
||||
query = query.Take(limit.Value);
|
||||
|
||||
var entities = await query.ToListAsync(cancellationToken);
|
||||
return entities.Select(ToModel).ToList();
|
||||
}
|
||||
|
||||
@@ -224,78 +249,87 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE vexhub.statements
|
||||
SET is_flagged = TRUE, flag_reason = @Reason
|
||||
WHERE id = @Id
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
await connection.ExecuteAsync(sql, new { Id = id, Reason = reason });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entity = await dbContext.Statements.FirstOrDefaultAsync(s => s.Id == id, cancellationToken);
|
||||
if (entity is not null)
|
||||
{
|
||||
entity.IsFlagged = true;
|
||||
entity.FlagReason = reason;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> DeleteBySourceAsync(
|
||||
string sourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "DELETE FROM vexhub.statements WHERE source_id = @SourceId";
|
||||
|
||||
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken);
|
||||
return await connection.ExecuteAsync(sql, new { SourceId = sourceId });
|
||||
await using var dbContext = VexHubDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
return await dbContext.Statements
|
||||
.Where(s => s.SourceId == sourceId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string ApplyFilter(string sql, VexStatementFilter filter, DynamicParameters parameters)
|
||||
private static IQueryable<VexStatement> ApplyFilter(IQueryable<VexStatement> query, VexStatementFilter filter)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter.SourceId))
|
||||
{
|
||||
sql += " AND source_id = @SourceId";
|
||||
parameters.Add("SourceId", filter.SourceId);
|
||||
}
|
||||
query = query.Where(s => s.SourceId == filter.SourceId);
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.VulnerabilityId))
|
||||
{
|
||||
sql += " AND vulnerability_id = @VulnerabilityId";
|
||||
parameters.Add("VulnerabilityId", filter.VulnerabilityId);
|
||||
}
|
||||
query = query.Where(s => s.VulnerabilityId == filter.VulnerabilityId);
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.ProductKey))
|
||||
{
|
||||
sql += " AND product_key = @ProductKey";
|
||||
parameters.Add("ProductKey", filter.ProductKey);
|
||||
}
|
||||
query = query.Where(s => s.ProductKey == filter.ProductKey);
|
||||
|
||||
if (filter.Status.HasValue)
|
||||
{
|
||||
sql += " AND status = @Status";
|
||||
parameters.Add("Status", filter.Status.Value.ToString().ToLowerInvariant());
|
||||
var statusStr = FormatStatus(filter.Status.Value);
|
||||
query = query.Where(s => s.Status == statusStr);
|
||||
}
|
||||
|
||||
if (filter.VerificationStatus.HasValue)
|
||||
{
|
||||
sql += " AND verification_status = @VerificationStatus";
|
||||
parameters.Add("VerificationStatus", filter.VerificationStatus.Value.ToString().ToLowerInvariant());
|
||||
var verStatusStr = filter.VerificationStatus.Value.ToString().ToLowerInvariant();
|
||||
query = query.Where(s => s.VerificationStatus == verStatusStr);
|
||||
}
|
||||
|
||||
if (filter.IsFlagged.HasValue)
|
||||
{
|
||||
sql += " AND is_flagged = @IsFlagged";
|
||||
parameters.Add("IsFlagged", filter.IsFlagged.Value);
|
||||
}
|
||||
query = query.Where(s => s.IsFlagged == filter.IsFlagged.Value);
|
||||
|
||||
if (filter.IngestedAfter.HasValue)
|
||||
{
|
||||
sql += " AND ingested_at >= @IngestedAfter";
|
||||
parameters.Add("IngestedAfter", filter.IngestedAfter.Value);
|
||||
var after = filter.IngestedAfter.Value.UtcDateTime;
|
||||
query = query.Where(s => s.IngestedAt >= after);
|
||||
}
|
||||
|
||||
if (filter.IngestedBefore.HasValue)
|
||||
{
|
||||
sql += " AND ingested_at <= @IngestedBefore";
|
||||
parameters.Add("IngestedBefore", filter.IngestedBefore.Value);
|
||||
var before = filter.IngestedBefore.Value.UtcDateTime;
|
||||
query = query.Where(s => s.IngestedAt <= before);
|
||||
}
|
||||
|
||||
if (filter.UpdatedAfter.HasValue)
|
||||
{
|
||||
sql += " AND source_updated_at >= @UpdatedAfter";
|
||||
parameters.Add("UpdatedAfter", filter.UpdatedAfter.Value);
|
||||
var updatedAfter = filter.UpdatedAfter.Value.UtcDateTime;
|
||||
query = query.Where(s => s.SourceUpdatedAt >= updatedAfter);
|
||||
}
|
||||
|
||||
return sql;
|
||||
return query;
|
||||
}
|
||||
|
||||
private static VexStatementEntity ToEntity(AggregatedVexStatement model) => new()
|
||||
private static string FormatStatus(VexStatus status) => status switch
|
||||
{
|
||||
VexStatus.NotAffected => "not_affected",
|
||||
VexStatus.Affected => "affected",
|
||||
VexStatus.Fixed => "fixed",
|
||||
VexStatus.UnderInvestigation => "under_investigation",
|
||||
_ => status.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static VexStatement ToEntity(AggregatedVexStatement model) => new()
|
||||
{
|
||||
Id = model.Id,
|
||||
SourceStatementId = model.SourceStatementId,
|
||||
@@ -304,29 +338,25 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
VulnerabilityId = model.VulnerabilityId,
|
||||
VulnerabilityAliases = model.VulnerabilityAliases?.ToArray(),
|
||||
ProductKey = model.ProductKey,
|
||||
Status = model.Status.ToString().ToLowerInvariant().Replace("notaffected", "not_affected").Replace("underinvestigation", "under_investigation"),
|
||||
Justification = model.Justification?.ToString().ToLowerInvariant().Replace("componentnotpresent", "component_not_present")
|
||||
.Replace("vulnerablecodenotpresent", "vulnerable_code_not_present")
|
||||
.Replace("vulnerablecodenotinexecutepath", "vulnerable_code_not_in_execute_path")
|
||||
.Replace("vulnerablecodecannotbecontrolledbyadversary", "vulnerable_code_cannot_be_controlled_by_adversary")
|
||||
.Replace("inlinemitigationsalreadyexist", "inline_mitigations_already_exist"),
|
||||
Status = FormatStatus(model.Status),
|
||||
Justification = model.Justification is not null ? FormatJustification(model.Justification.Value) : null,
|
||||
StatusNotes = model.StatusNotes,
|
||||
ImpactStatement = model.ImpactStatement,
|
||||
ActionStatement = model.ActionStatement,
|
||||
Versions = model.Versions is not null ? JsonSerializer.Serialize(model.Versions) : null,
|
||||
IssuedAt = model.IssuedAt,
|
||||
SourceUpdatedAt = model.SourceUpdatedAt,
|
||||
IssuedAt = model.IssuedAt?.UtcDateTime,
|
||||
SourceUpdatedAt = model.SourceUpdatedAt?.UtcDateTime,
|
||||
VerificationStatus = model.VerificationStatus.ToString().ToLowerInvariant(),
|
||||
VerifiedAt = model.VerifiedAt,
|
||||
VerifiedAt = model.VerifiedAt?.UtcDateTime,
|
||||
SigningKeyFingerprint = model.SigningKeyFingerprint,
|
||||
IsFlagged = model.IsFlagged,
|
||||
FlagReason = model.FlagReason,
|
||||
IngestedAt = model.IngestedAt,
|
||||
UpdatedAt = model.UpdatedAt,
|
||||
IngestedAt = model.IngestedAt.UtcDateTime,
|
||||
UpdatedAt = model.UpdatedAt?.UtcDateTime,
|
||||
ContentDigest = model.ContentDigest
|
||||
};
|
||||
|
||||
private static AggregatedVexStatement ToModel(VexStatementEntity entity) => new()
|
||||
private static AggregatedVexStatement ToModel(VexStatement entity) => new()
|
||||
{
|
||||
Id = entity.Id,
|
||||
SourceStatementId = entity.SourceStatementId,
|
||||
@@ -341,18 +371,28 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
ImpactStatement = entity.ImpactStatement,
|
||||
ActionStatement = entity.ActionStatement,
|
||||
Versions = entity.Versions is not null ? JsonSerializer.Deserialize<VersionRange>(entity.Versions) : null,
|
||||
IssuedAt = entity.IssuedAt,
|
||||
SourceUpdatedAt = entity.SourceUpdatedAt,
|
||||
IssuedAt = entity.IssuedAt.HasValue ? new DateTimeOffset(entity.IssuedAt.Value, TimeSpan.Zero) : null,
|
||||
SourceUpdatedAt = entity.SourceUpdatedAt.HasValue ? new DateTimeOffset(entity.SourceUpdatedAt.Value, TimeSpan.Zero) : null,
|
||||
VerificationStatus = Enum.Parse<VerificationStatus>(entity.VerificationStatus, ignoreCase: true),
|
||||
VerifiedAt = entity.VerifiedAt,
|
||||
VerifiedAt = entity.VerifiedAt.HasValue ? new DateTimeOffset(entity.VerifiedAt.Value, TimeSpan.Zero) : null,
|
||||
SigningKeyFingerprint = entity.SigningKeyFingerprint,
|
||||
IsFlagged = entity.IsFlagged,
|
||||
FlagReason = entity.FlagReason,
|
||||
IngestedAt = entity.IngestedAt,
|
||||
UpdatedAt = entity.UpdatedAt,
|
||||
IngestedAt = new DateTimeOffset(entity.IngestedAt, TimeSpan.Zero),
|
||||
UpdatedAt = entity.UpdatedAt.HasValue ? new DateTimeOffset(entity.UpdatedAt.Value, TimeSpan.Zero) : null,
|
||||
ContentDigest = entity.ContentDigest
|
||||
};
|
||||
|
||||
private static string FormatJustification(VexJustification justification) => justification switch
|
||||
{
|
||||
VexJustification.ComponentNotPresent => "component_not_present",
|
||||
VexJustification.VulnerableCodeNotPresent => "vulnerable_code_not_present",
|
||||
VexJustification.VulnerableCodeNotInExecutePath => "vulnerable_code_not_in_execute_path",
|
||||
VexJustification.VulnerableCodeCannotBeControlledByAdversary => "vulnerable_code_cannot_be_controlled_by_adversary",
|
||||
VexJustification.InlineMitigationsAlreadyExist => "inline_mitigations_already_exist",
|
||||
_ => justification.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static VexStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"not_affected" => VexStatus.NotAffected,
|
||||
@@ -371,4 +411,6 @@ public sealed class PostgresVexStatementRepository : IVexStatementRepository
|
||||
"inline_mitigations_already_exist" => VexJustification.InlineMitigationsAlreadyExist,
|
||||
_ => throw new ArgumentException($"Unknown justification: {justification}")
|
||||
};
|
||||
|
||||
private string GetSchemaName() => VexHubDataSource.DefaultSchemaName;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.VexHub.Persistence.EfCore.CompiledModels;
|
||||
using StellaOps.VexHub.Persistence.EfCore.Context;
|
||||
|
||||
namespace StellaOps.VexHub.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="VexHubDbContext"/> instances.
|
||||
/// Uses the static compiled model when schema matches the default; falls back to
|
||||
/// reflection-based model building for non-default schemas (integration tests).
|
||||
/// </summary>
|
||||
internal static class VexHubDbContextFactory
|
||||
{
|
||||
public static VexHubDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? VexHubDataSource.DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<VexHubDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, VexHubDataSource.DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(VexHubDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new VexHubDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
@@ -26,6 +25,11 @@
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\VexHubDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user