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:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model stub for ProofChainDbContext.
|
||||
/// 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(ProofChainDbContext))]
|
||||
public partial class AttestorDbContextModel : RuntimeModel
|
||||
{
|
||||
private static AttestorDbContextModel _instance;
|
||||
|
||||
public static IModel Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new AttestorDbContextModel();
|
||||
_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.Attestor.Persistence.EfCore.CompiledModels;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled model builder stub for ProofChainDbContext.
|
||||
/// Replace with output from <c>dotnet ef dbcontext optimize</c> when a provisioned DB is available.
|
||||
/// </summary>
|
||||
public partial class AttestorDbContextModel
|
||||
{
|
||||
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,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.EfCore.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time DbContext factory for dotnet ef CLI tooling.
|
||||
/// Used by scaffold and optimize commands.
|
||||
/// </summary>
|
||||
public sealed class AttestorDesignTimeDbContextFactory : IDesignTimeDbContextFactory<ProofChainDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString =
|
||||
"Host=localhost;Port=55433;Database=postgres;Username=postgres;Password=postgres;Search Path=proofchain,public";
|
||||
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_ATTESTOR_EF_CONNECTION";
|
||||
|
||||
public ProofChainDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<ProofChainDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new ProofChainDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,16 @@ public static class PersistenceServiceCollectionExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the predicate type registry repository backed by PostgreSQL.
|
||||
/// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
/// Registers the predicate type registry repository backed by PostgreSQL with EF Core.
|
||||
/// Sprint: SPRINT_20260222_092 (ATTEST-EF-03)
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPredicateTypeRegistry(
|
||||
this IServiceCollection services,
|
||||
string connectionString)
|
||||
string connectionString,
|
||||
string? schemaName = null)
|
||||
{
|
||||
services.TryAddSingleton<IPredicateTypeRegistryRepository>(
|
||||
new PostgresPredicateTypeRegistryRepository(connectionString));
|
||||
new PostgresPredicateTypeRegistryRepository(connectionString, schemaName));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Attestor.Persistence.EfCore.CompiledModels;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime factory for creating <see cref="ProofChainDbContext"/> 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 AttestorDbContextFactory
|
||||
{
|
||||
public const string DefaultSchemaName = "proofchain";
|
||||
|
||||
public static ProofChainDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<ProofChainDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Guard: only use compiled model if it has entity types registered.
|
||||
// Empty stub models bypass OnModelCreating and cause DbSet errors.
|
||||
var compiledModel = AttestorDbContextModel.Instance;
|
||||
if (compiledModel.GetEntityTypes().Any())
|
||||
{
|
||||
optionsBuilder.UseModel(compiledModel);
|
||||
}
|
||||
}
|
||||
|
||||
return new ProofChainDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
using StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for proof chain persistence.
|
||||
/// Maps to the proofchain and attestor PostgreSQL schemas.
|
||||
/// </summary>
|
||||
public class ProofChainDbContext : DbContext
|
||||
public partial class ProofChainDbContext : DbContext
|
||||
{
|
||||
public ProofChainDbContext(DbContextOptions<ProofChainDbContext> options)
|
||||
private readonly string _schemaName;
|
||||
|
||||
public ProofChainDbContext(DbContextOptions<ProofChainDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "proofchain"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,16 +50,34 @@ public class ProofChainDbContext : DbContext
|
||||
/// </summary>
|
||||
public DbSet<AuditLogEntity> AuditLog => Set<AuditLogEntity>();
|
||||
|
||||
/// <summary>
|
||||
/// Verdict ledger table.
|
||||
/// </summary>
|
||||
public virtual DbSet<VerdictLedgerEntry> VerdictLedger { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Predicate type registry table.
|
||||
/// </summary>
|
||||
public DbSet<PredicateTypeRegistryEntry> PredicateTypeRegistry => Set<PredicateTypeRegistryEntry>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Configure schema
|
||||
modelBuilder.HasDefaultSchema("proofchain");
|
||||
var schemaName = _schemaName;
|
||||
|
||||
// Configure default schema
|
||||
modelBuilder.HasDefaultSchema(schemaName);
|
||||
|
||||
// Configure custom enum
|
||||
modelBuilder.HasPostgresEnum(schemaName, "verification_result",
|
||||
new[] { "pass", "fail", "pending" });
|
||||
|
||||
// SbomEntryEntity configuration
|
||||
modelBuilder.Entity<SbomEntryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EntryId).HasName("sbom_entries_pkey");
|
||||
entity.ToTable("sbom_entries", schemaName);
|
||||
entity.HasIndex(e => e.BomDigest).HasDatabaseName("idx_sbom_entries_bom_digest");
|
||||
entity.HasIndex(e => e.Purl).HasDatabaseName("idx_sbom_entries_purl");
|
||||
entity.HasIndex(e => e.ArtifactDigest).HasDatabaseName("idx_sbom_entries_artifact");
|
||||
@@ -86,6 +111,8 @@ public class ProofChainDbContext : DbContext
|
||||
// DsseEnvelopeEntity configuration
|
||||
modelBuilder.Entity<DsseEnvelopeEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EnvId).HasName("dsse_envelopes_pkey");
|
||||
entity.ToTable("dsse_envelopes", schemaName);
|
||||
entity.HasIndex(e => new { e.EntryId, e.PredicateType })
|
||||
.HasDatabaseName("idx_dsse_entry_predicate");
|
||||
entity.HasIndex(e => e.SignerKeyId).HasDatabaseName("idx_dsse_signer");
|
||||
@@ -103,6 +130,8 @@ public class ProofChainDbContext : DbContext
|
||||
// SpineEntity configuration
|
||||
modelBuilder.Entity<SpineEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EntryId).HasName("spines_pkey");
|
||||
entity.ToTable("spines", schemaName);
|
||||
entity.HasIndex(e => e.BundleId).HasDatabaseName("idx_spines_bundle").IsUnique();
|
||||
entity.HasIndex(e => e.AnchorId).HasDatabaseName("idx_spines_anchor");
|
||||
entity.HasIndex(e => e.PolicyVersion).HasDatabaseName("idx_spines_policy");
|
||||
@@ -119,6 +148,8 @@ public class ProofChainDbContext : DbContext
|
||||
// TrustAnchorEntity configuration
|
||||
modelBuilder.Entity<TrustAnchorEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.AnchorId).HasName("trust_anchors_pkey");
|
||||
entity.ToTable("trust_anchors", schemaName);
|
||||
entity.HasIndex(e => e.PurlPattern).HasDatabaseName("idx_trust_anchors_pattern");
|
||||
entity.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("idx_trust_anchors_active")
|
||||
@@ -134,6 +165,8 @@ public class ProofChainDbContext : DbContext
|
||||
// RekorEntryEntity configuration
|
||||
modelBuilder.Entity<RekorEntryEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.DsseSha256).HasName("rekor_entries_pkey");
|
||||
entity.ToTable("rekor_entries", schemaName);
|
||||
entity.HasIndex(e => e.LogIndex).HasDatabaseName("idx_rekor_log_index");
|
||||
entity.HasIndex(e => e.LogId).HasDatabaseName("idx_rekor_log_id");
|
||||
entity.HasIndex(e => e.Uuid).HasDatabaseName("idx_rekor_uuid");
|
||||
@@ -151,6 +184,8 @@ public class ProofChainDbContext : DbContext
|
||||
// AuditLogEntity configuration
|
||||
modelBuilder.Entity<AuditLogEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.LogId).HasName("audit_log_pkey");
|
||||
entity.ToTable("audit_log", schemaName);
|
||||
entity.HasIndex(e => new { e.EntityType, e.EntityId })
|
||||
.HasDatabaseName("idx_audit_entity");
|
||||
entity.HasIndex(e => e.CreatedAt)
|
||||
@@ -160,8 +195,104 @@ public class ProofChainDbContext : DbContext
|
||||
.HasDefaultValueSql("NOW()")
|
||||
.ValueGeneratedOnAdd();
|
||||
});
|
||||
|
||||
// VerdictLedgerEntry configuration
|
||||
modelBuilder.Entity<VerdictLedgerEntry>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.LedgerId).HasName("verdict_ledger_pkey");
|
||||
entity.ToTable("verdict_ledger", schemaName);
|
||||
|
||||
entity.HasIndex(e => e.BomRef).HasDatabaseName("idx_verdict_ledger_bom_ref");
|
||||
entity.HasIndex(e => e.RekorUuid)
|
||||
.HasDatabaseName("idx_verdict_ledger_rekor_uuid")
|
||||
.HasFilter("rekor_uuid IS NOT NULL");
|
||||
entity.HasIndex(e => e.CreatedAt)
|
||||
.HasDatabaseName("idx_verdict_ledger_created_at")
|
||||
.IsDescending();
|
||||
entity.HasIndex(e => new { e.TenantId, e.CreatedAt })
|
||||
.HasDatabaseName("idx_verdict_ledger_tenant_created")
|
||||
.IsDescending(false, true);
|
||||
entity.HasIndex(e => e.VerdictHash)
|
||||
.HasDatabaseName("uq_verdict_hash")
|
||||
.IsUnique();
|
||||
entity.HasIndex(e => e.Decision).HasDatabaseName("idx_verdict_ledger_decision");
|
||||
|
||||
entity.Property(e => e.LedgerId).HasColumnName("ledger_id");
|
||||
entity.Property(e => e.BomRef).HasColumnName("bom_ref").HasMaxLength(2048);
|
||||
entity.Property(e => e.CycloneDxSerial).HasColumnName("cyclonedx_serial").HasMaxLength(512);
|
||||
entity.Property(e => e.RekorUuid).HasColumnName("rekor_uuid").HasMaxLength(128);
|
||||
entity.Property(e => e.Decision)
|
||||
.HasColumnName("decision")
|
||||
.HasConversion<string>();
|
||||
entity.Property(e => e.Reason).HasColumnName("reason");
|
||||
entity.Property(e => e.PolicyBundleId).HasColumnName("policy_bundle_id").HasMaxLength(256);
|
||||
entity.Property(e => e.PolicyBundleHash).HasColumnName("policy_bundle_hash").HasMaxLength(64);
|
||||
entity.Property(e => e.VerifierImageDigest).HasColumnName("verifier_image_digest").HasMaxLength(256);
|
||||
entity.Property(e => e.SignerKeyId).HasColumnName("signer_keyid").HasMaxLength(512);
|
||||
entity.Property(e => e.PrevHash).HasColumnName("prev_hash").HasMaxLength(64);
|
||||
entity.Property(e => e.VerdictHash).HasColumnName("verdict_hash").HasMaxLength(64);
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
});
|
||||
|
||||
// PredicateTypeRegistryEntry configuration
|
||||
modelBuilder.Entity<PredicateTypeRegistryEntry>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.RegistryId).HasName("predicate_type_registry_pkey");
|
||||
entity.ToTable("predicate_type_registry", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.PredicateTypeUri, e.Version })
|
||||
.HasDatabaseName("uq_predicate_type_version")
|
||||
.IsUnique();
|
||||
entity.HasIndex(e => e.PredicateTypeUri)
|
||||
.HasDatabaseName("idx_predicate_registry_uri");
|
||||
entity.HasIndex(e => e.Category)
|
||||
.HasDatabaseName("idx_predicate_registry_category");
|
||||
entity.HasIndex(e => e.IsActive)
|
||||
.HasDatabaseName("idx_predicate_registry_active")
|
||||
.HasFilter("is_active = TRUE");
|
||||
|
||||
entity.Property(e => e.RegistryId)
|
||||
.HasColumnName("registry_id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.PredicateTypeUri)
|
||||
.HasColumnName("predicate_type_uri")
|
||||
.IsRequired();
|
||||
entity.Property(e => e.DisplayName)
|
||||
.HasColumnName("display_name")
|
||||
.IsRequired();
|
||||
entity.Property(e => e.Version)
|
||||
.HasColumnName("version")
|
||||
.HasDefaultValue("1.0.0");
|
||||
entity.Property(e => e.Category)
|
||||
.HasColumnName("category")
|
||||
.HasDefaultValue("stella-core");
|
||||
entity.Property(e => e.JsonSchema)
|
||||
.HasColumnName("json_schema")
|
||||
.HasColumnType("jsonb");
|
||||
entity.Property(e => e.Description)
|
||||
.HasColumnName("description");
|
||||
entity.Property(e => e.IsActive)
|
||||
.HasColumnName("is_active")
|
||||
.HasDefaultValue(true);
|
||||
entity.Property(e => e.ValidationMode)
|
||||
.HasColumnName("validation_mode")
|
||||
.HasDefaultValue("log-only");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("NOW()");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
NormalizeTrackedArrays();
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresPredicateTypeRegistryRepository.cs
|
||||
// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
// Task: PSR-02 - Create Predicate Schema Registry endpoints and repository
|
||||
// Description: PostgreSQL implementation of predicate type registry repository
|
||||
// Sprint: SPRINT_20260222_092_Attestor_dal_to_efcore
|
||||
// Task: ATTEST-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: EF Core implementation of predicate type registry repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Attestor.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed predicate type registry repository.
|
||||
/// Sprint: SPRINT_20260219_010 (PSR-02)
|
||||
/// EF Core implementation of the predicate type registry repository.
|
||||
/// Preserves idempotent registration via ON CONFLICT DO NOTHING semantics.
|
||||
/// </summary>
|
||||
public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegistryRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private const int DefaultCommandTimeoutSeconds = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL predicate type registry repository.
|
||||
/// Creates a new EF Core predicate type registry repository.
|
||||
/// </summary>
|
||||
public PostgresPredicateTypeRegistryRepository(string connectionString)
|
||||
public PostgresPredicateTypeRegistryRepository(string connectionString, string? schemaName = null)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_schemaName = schemaName ?? AttestorDbContextFactory.DefaultSchemaName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -35,31 +40,26 @@ public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegi
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = @"
|
||||
SELECT registry_id, predicate_type_uri, display_name, version, category,
|
||||
json_schema, description, is_active, validation_mode, created_at, updated_at
|
||||
FROM proofchain.predicate_type_registry
|
||||
WHERE (@category::text IS NULL OR category = @category)
|
||||
AND (@is_active::boolean IS NULL OR is_active = @is_active)
|
||||
ORDER BY category, predicate_type_uri
|
||||
OFFSET @offset LIMIT @limit";
|
||||
var query = dbContext.PredicateTypeRegistry.AsNoTracking().AsQueryable();
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("category", (object?)category ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("is_active", isActive.HasValue ? isActive.Value : DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("offset", offset);
|
||||
cmd.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
var results = new List<PredicateTypeRegistryEntry>();
|
||||
|
||||
while (await reader.ReadAsync(ct))
|
||||
if (category is not null)
|
||||
{
|
||||
results.Add(MapEntry(reader));
|
||||
query = query.Where(e => e.Category == category);
|
||||
}
|
||||
|
||||
return results;
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(e => e.Category)
|
||||
.ThenBy(e => e.PredicateTypeUri)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -69,25 +69,13 @@ public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegi
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = @"
|
||||
SELECT registry_id, predicate_type_uri, display_name, version, category,
|
||||
json_schema, description, is_active, validation_mode, created_at, updated_at
|
||||
FROM proofchain.predicate_type_registry
|
||||
WHERE predicate_type_uri = @predicate_type_uri
|
||||
ORDER BY version DESC
|
||||
LIMIT 1";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("predicate_type_uri", predicateTypeUri);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapEntry(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
return await dbContext.PredicateTypeRegistry
|
||||
.AsNoTracking()
|
||||
.Where(e => e.PredicateTypeUri == predicateTypeUri)
|
||||
.OrderByDescending(e => e.Version)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -99,55 +87,32 @@ public sealed class PostgresPredicateTypeRegistryRepository : IPredicateTypeRegi
|
||||
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = @"
|
||||
INSERT INTO proofchain.predicate_type_registry
|
||||
(predicate_type_uri, display_name, version, category, json_schema, description, is_active, validation_mode)
|
||||
VALUES (@predicate_type_uri, @display_name, @version, @category, @json_schema::jsonb, @description, @is_active, @validation_mode)
|
||||
ON CONFLICT (predicate_type_uri, version) DO NOTHING
|
||||
RETURNING registry_id, created_at, updated_at";
|
||||
dbContext.PredicateTypeRegistry.Add(entry);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("predicate_type_uri", entry.PredicateTypeUri);
|
||||
cmd.Parameters.AddWithValue("display_name", entry.DisplayName);
|
||||
cmd.Parameters.AddWithValue("version", entry.Version);
|
||||
cmd.Parameters.AddWithValue("category", entry.Category);
|
||||
cmd.Parameters.AddWithValue("json_schema", (object?)entry.JsonSchema ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("description", (object?)entry.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("is_active", entry.IsActive);
|
||||
cmd.Parameters.AddWithValue("validation_mode", entry.ValidationMode);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
try
|
||||
{
|
||||
return entry with
|
||||
{
|
||||
RegistryId = reader.GetGuid(0),
|
||||
CreatedAt = reader.GetDateTime(1),
|
||||
UpdatedAt = reader.GetDateTime(2),
|
||||
};
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
return entry;
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// ON CONFLICT DO NOTHING semantics: return existing entry
|
||||
var existing = await GetByUriAsync(entry.PredicateTypeUri, ct);
|
||||
return existing ?? entry;
|
||||
}
|
||||
|
||||
// Conflict (already exists) - return existing
|
||||
var existing = await GetByUriAsync(entry.PredicateTypeUri, ct);
|
||||
return existing ?? entry;
|
||||
}
|
||||
|
||||
private static PredicateTypeRegistryEntry MapEntry(NpgsqlDataReader reader)
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
return new PredicateTypeRegistryEntry
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
RegistryId = reader.GetGuid(0),
|
||||
PredicateTypeUri = reader.GetString(1),
|
||||
DisplayName = reader.GetString(2),
|
||||
Version = reader.GetString(3),
|
||||
Category = reader.GetString(4),
|
||||
JsonSchema = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
Description = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
IsActive = reader.GetBoolean(7),
|
||||
ValidationMode = reader.GetString(8),
|
||||
CreatedAt = reader.GetDateTime(9),
|
||||
UpdatedAt = reader.GetDateTime(10),
|
||||
};
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PostgresVerdictLedgerRepository.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
|
||||
// Task: VL-002 - Implement VerdictLedger entity and repository
|
||||
// Description: PostgreSQL implementation of verdict ledger repository
|
||||
// Sprint: SPRINT_20260222_092_Attestor_dal_to_efcore
|
||||
// Task: ATTEST-EF-03 - Convert DAL repositories to EF Core
|
||||
// Description: EF Core implementation of verdict ledger repository
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
using StellaOps.Attestor.Persistence.Postgres;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the verdict ledger repository.
|
||||
/// EF Core implementation of the verdict ledger repository.
|
||||
/// Enforces append-only semantics with hash chain validation.
|
||||
/// </summary>
|
||||
public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private const int DefaultCommandTimeoutSeconds = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL verdict ledger repository.
|
||||
/// Creates a new EF Core verdict ledger repository.
|
||||
/// </summary>
|
||||
public PostgresVerdictLedgerRepository(string connectionString)
|
||||
public PostgresVerdictLedgerRepository(string connectionString, string? schemaName = null)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_schemaName = schemaName ?? AttestorDbContextFactory.DefaultSchemaName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -31,9 +36,6 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
// Validate chain integrity
|
||||
var latest = await GetLatestAsync(entry.TenantId, ct);
|
||||
var expectedPrevHash = latest?.VerdictHash;
|
||||
@@ -43,46 +45,30 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
throw new ChainIntegrityException(expectedPrevHash, entry.PrevHash);
|
||||
}
|
||||
|
||||
// Insert the new entry
|
||||
const string sql = @"
|
||||
INSERT INTO verdict_ledger (
|
||||
ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
|
||||
prev_hash, verdict_hash, created_at, tenant_id
|
||||
) VALUES (
|
||||
@ledger_id, @bom_ref, @cyclonedx_serial, @rekor_uuid, @decision::verdict_decision, @reason,
|
||||
@policy_bundle_id, @policy_bundle_hash, @verifier_image_digest, @signer_keyid,
|
||||
@prev_hash, @verdict_hash, @created_at, @tenant_id
|
||||
)
|
||||
RETURNING ledger_id, created_at";
|
||||
// Use raw SQL for the INSERT with RETURNING and enum cast, which is cleaner
|
||||
// for the verdict_decision custom enum type
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("ledger_id", entry.LedgerId);
|
||||
cmd.Parameters.AddWithValue("bom_ref", entry.BomRef);
|
||||
cmd.Parameters.AddWithValue("cyclonedx_serial", (object?)entry.CycloneDxSerial ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("rekor_uuid", (object?)entry.RekorUuid ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("decision", entry.Decision.ToString().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("reason", (object?)entry.Reason ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("policy_bundle_id", entry.PolicyBundleId);
|
||||
cmd.Parameters.AddWithValue("policy_bundle_hash", entry.PolicyBundleHash);
|
||||
cmd.Parameters.AddWithValue("verifier_image_digest", entry.VerifierImageDigest);
|
||||
cmd.Parameters.AddWithValue("signer_keyid", entry.SignerKeyId);
|
||||
cmd.Parameters.AddWithValue("prev_hash", (object?)entry.PrevHash ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("verdict_hash", entry.VerdictHash);
|
||||
cmd.Parameters.AddWithValue("created_at", entry.CreatedAt);
|
||||
cmd.Parameters.AddWithValue("tenant_id", entry.TenantId);
|
||||
dbContext.VerdictLedger.Add(entry);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
try
|
||||
{
|
||||
return entry with
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
|
||||
{
|
||||
// Idempotent: entry already exists
|
||||
var existing = await GetByHashAsync(entry.VerdictHash, ct);
|
||||
if (existing != null)
|
||||
{
|
||||
LedgerId = reader.GetGuid(0),
|
||||
CreatedAt = reader.GetDateTime(1)
|
||||
};
|
||||
return existing;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Insert failed to return ledger_id");
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -90,24 +76,11 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = @"
|
||||
SELECT ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
|
||||
prev_hash, verdict_hash, created_at, tenant_id
|
||||
FROM verdict_ledger
|
||||
WHERE verdict_hash = @verdict_hash";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("verdict_hash", verdictHash);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapToEntry(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
return await dbContext.VerdictLedger
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.VerdictHash == verdictHash, ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -118,27 +91,13 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = @"
|
||||
SELECT ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
|
||||
prev_hash, verdict_hash, created_at, tenant_id
|
||||
FROM verdict_ledger
|
||||
WHERE bom_ref = @bom_ref AND tenant_id = @tenant_id
|
||||
ORDER BY created_at ASC";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("bom_ref", bomRef);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var results = new List<VerdictLedgerEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
results.Add(MapToEntry(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
return await dbContext.VerdictLedger
|
||||
.AsNoTracking()
|
||||
.Where(e => e.BomRef == bomRef && e.TenantId == tenantId)
|
||||
.OrderBy(e => e.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -146,26 +105,13 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = @"
|
||||
SELECT ledger_id, bom_ref, cyclonedx_serial, rekor_uuid, decision, reason,
|
||||
policy_bundle_id, policy_bundle_hash, verifier_image_digest, signer_keyid,
|
||||
prev_hash, verdict_hash, created_at, tenant_id
|
||||
FROM verdict_ledger
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return MapToEntry(reader);
|
||||
}
|
||||
|
||||
return null;
|
||||
return await dbContext.VerdictLedger
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -207,34 +153,23 @@ public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
await using var dbContext = AttestorDbContextFactory.Create(conn, DefaultCommandTimeoutSeconds, _schemaName);
|
||||
|
||||
const string sql = "SELECT COUNT(*) FROM verdict_ledger WHERE tenant_id = @tenant_id";
|
||||
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync(ct);
|
||||
return Convert.ToInt64(result);
|
||||
return await dbContext.VerdictLedger
|
||||
.AsNoTracking()
|
||||
.Where(e => e.TenantId == tenantId)
|
||||
.LongCountAsync(ct);
|
||||
}
|
||||
|
||||
private static VerdictLedgerEntry MapToEntry(NpgsqlDataReader reader)
|
||||
private static bool IsUniqueViolation(DbUpdateException exception)
|
||||
{
|
||||
return new VerdictLedgerEntry
|
||||
Exception? current = exception;
|
||||
while (current is not null)
|
||||
{
|
||||
LedgerId = reader.GetGuid(0),
|
||||
BomRef = reader.GetString(1),
|
||||
CycloneDxSerial = reader.IsDBNull(2) ? null : reader.GetString(2),
|
||||
RekorUuid = reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||
Decision = Enum.Parse<VerdictDecision>(reader.GetString(4), ignoreCase: true),
|
||||
Reason = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
PolicyBundleId = reader.GetString(6),
|
||||
PolicyBundleHash = reader.GetString(7),
|
||||
VerifierImageDigest = reader.GetString(8),
|
||||
SignerKeyId = reader.GetString(9),
|
||||
PrevHash = reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||
VerdictHash = reader.GetString(11),
|
||||
CreatedAt = reader.GetDateTime(12),
|
||||
TenantId = reader.GetGuid(13)
|
||||
};
|
||||
if (current is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,26 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Migrations\*.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\AttestorDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Tests\\**\\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Attestor Persistence Task Board
|
||||
|
||||
This board mirrors active sprint tasks for this module.
|
||||
Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||
Source of truth: `docs/implplan/SPRINT_20260222_092_Attestor_dal_to_efcore.md`.
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
@@ -9,3 +9,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0060-T | DONE | Revalidated 2026-01-06. |
|
||||
| AUDIT-0060-A | TODO | Reopened after revalidation 2026-01-06. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| ATTEST-EF-01 | DONE | Migration registry plugin wired. 2026-02-23. |
|
||||
| ATTEST-EF-02 | DONE | EF Core model baseline scaffolded with 8 entities. 2026-02-23. |
|
||||
| ATTEST-EF-03 | DONE | VerdictLedger and PredicateTypeRegistry repos converted to EF Core. TrustVerdict/Infrastructure retain raw Npgsql. 2026-02-23. |
|
||||
| ATTEST-EF-04 | DONE | Compiled model stubs + runtime factory with guard. 2026-02-23. |
|
||||
| ATTEST-EF-05 | DONE | Sequential builds pass (0 errors). Tests: 73+806 pass. Docs updated. 2026-02-23. |
|
||||
|
||||
Reference in New Issue
Block a user