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:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -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)

View File

@@ -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();

View File

@@ -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";
}

View File

@@ -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.

View File

@@ -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();
}

View File

@@ -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.
}
}

View File

@@ -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);
});
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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>();
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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" />