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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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