doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VerdictLedgerEntry.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
|
||||
// Task: VL-002 - Implement VerdictLedger entity and repository
|
||||
// Description: Domain entity for append-only verdict ledger
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an entry in the append-only verdict ledger.
|
||||
/// Each entry is cryptographically chained to the previous entry via SHA-256 hashes.
|
||||
/// </summary>
|
||||
public sealed record VerdictLedgerEntry
|
||||
{
|
||||
/// <summary>Primary identifier.</summary>
|
||||
public Guid LedgerId { get; init; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Package URL or container digest reference.</summary>
|
||||
public required string BomRef { get; init; }
|
||||
|
||||
/// <summary>CycloneDX serialNumber URN (urn:uuid:...).</summary>
|
||||
public string? CycloneDxSerial { get; init; }
|
||||
|
||||
/// <summary>Rekor transparency log entry UUID (populated after submission).</summary>
|
||||
public string? RekorUuid { get; init; }
|
||||
|
||||
/// <summary>Verdict decision.</summary>
|
||||
public VerdictDecision Decision { get; init; } = VerdictDecision.Unknown;
|
||||
|
||||
/// <summary>Human-readable reason for this verdict.</summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>Policy bundle identifier used for this decision.</summary>
|
||||
public required string PolicyBundleId { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of policy bundle content.</summary>
|
||||
public required string PolicyBundleHash { get; init; }
|
||||
|
||||
/// <summary>Container digest of the verifier service that made this decision.</summary>
|
||||
public required string VerifierImageDigest { get; init; }
|
||||
|
||||
/// <summary>Key ID that signed this verdict.</summary>
|
||||
public required string SignerKeyId { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of the previous entry (null for genesis).</summary>
|
||||
public string? PrevHash { get; init; }
|
||||
|
||||
/// <summary>SHA-256 hash of this entry's canonical JSON form.</summary>
|
||||
public required string VerdictHash { get; init; }
|
||||
|
||||
/// <summary>When this entry was created (UTC).</summary>
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Tenant identifier for multi-tenancy.</summary>
|
||||
public Guid TenantId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verdict decision enum.
|
||||
/// </summary>
|
||||
public enum VerdictDecision
|
||||
{
|
||||
/// <summary>Verdict not yet determined.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Approved for release.</summary>
|
||||
Approve = 1,
|
||||
|
||||
/// <summary>Rejected - do not release.</summary>
|
||||
Reject = 2,
|
||||
|
||||
/// <summary>Pending human review.</summary>
|
||||
Pending = 3
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 001_create_verdict_ledger.sql
|
||||
-- Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
|
||||
-- Task: VL-001 - Create VerdictLedger database schema
|
||||
-- Description: Append-only verdict ledger with SHA-256 hash chaining
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Create decision enum
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'verdict_decision') THEN
|
||||
CREATE TYPE verdict_decision AS ENUM ('unknown', 'approve', 'reject', 'pending');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
-- Create verdict_ledger table
|
||||
CREATE TABLE IF NOT EXISTS verdict_ledger (
|
||||
-- Primary identifier
|
||||
ledger_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- Package/artifact reference
|
||||
bom_ref VARCHAR(2048) NOT NULL,
|
||||
|
||||
-- CycloneDX serial number (URN format)
|
||||
cyclonedx_serial VARCHAR(256),
|
||||
|
||||
-- Transparency log reference (populated after Rekor submission)
|
||||
rekor_uuid VARCHAR(128),
|
||||
|
||||
-- Verdict decision
|
||||
decision verdict_decision NOT NULL DEFAULT 'unknown',
|
||||
|
||||
-- Human-readable reason for decision
|
||||
reason TEXT,
|
||||
|
||||
-- Policy configuration reference
|
||||
policy_bundle_id VARCHAR(256) NOT NULL,
|
||||
policy_bundle_hash VARCHAR(64) NOT NULL, -- SHA-256 hex
|
||||
|
||||
-- Verifier provenance
|
||||
verifier_image_digest VARCHAR(256) NOT NULL,
|
||||
|
||||
-- Signing key reference
|
||||
signer_keyid VARCHAR(256) NOT NULL,
|
||||
|
||||
-- Hash chain fields (append-only integrity)
|
||||
prev_hash VARCHAR(64), -- NULL for genesis entry
|
||||
verdict_hash VARCHAR(64) NOT NULL, -- SHA-256 of canonical entry
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Multi-tenancy
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT uq_verdict_hash UNIQUE (verdict_hash)
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_bom_ref ON verdict_ledger (bom_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_rekor_uuid ON verdict_ledger (rekor_uuid) WHERE rekor_uuid IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_created_at ON verdict_ledger (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_tenant ON verdict_ledger (tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_decision ON verdict_ledger (decision, created_at DESC);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE verdict_ledger IS 'Append-only cryptographic audit trail for release verdicts';
|
||||
COMMENT ON COLUMN verdict_ledger.prev_hash IS 'SHA-256 of previous entry; NULL for genesis';
|
||||
COMMENT ON COLUMN verdict_ledger.verdict_hash IS 'SHA-256 of canonical JSON representation of this entry';
|
||||
|
||||
-- Revoke UPDATE/DELETE for application role (enforce append-only)
|
||||
-- Note: Run this after creating the application role
|
||||
-- REVOKE UPDATE, DELETE ON verdict_ledger FROM stella_app;
|
||||
-- GRANT INSERT, SELECT ON verdict_ledger TO stella_app;
|
||||
@@ -0,0 +1,83 @@
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 001_verdict_ledger_initial.sql
|
||||
-- Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
|
||||
-- Task: VL-001 - Create VerdictLedger database schema
|
||||
-- Description: Append-only verdict ledger with SHA-256 hash chaining
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Create verdict decision enum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE verdict_decision AS ENUM ('unknown', 'approve', 'reject', 'pending');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- Create the verdict_ledger table
|
||||
CREATE TABLE IF NOT EXISTS verdict_ledger (
|
||||
ledger_id UUID PRIMARY KEY,
|
||||
bom_ref VARCHAR(2048) NOT NULL,
|
||||
cyclonedx_serial VARCHAR(512),
|
||||
rekor_uuid VARCHAR(128),
|
||||
decision verdict_decision NOT NULL DEFAULT 'unknown',
|
||||
reason TEXT NOT NULL,
|
||||
policy_bundle_id VARCHAR(256) NOT NULL,
|
||||
policy_bundle_hash VARCHAR(64) NOT NULL,
|
||||
verifier_image_digest VARCHAR(256) NOT NULL,
|
||||
signer_keyid VARCHAR(512) NOT NULL,
|
||||
prev_hash VARCHAR(64), -- SHA-256 hex, null for genesis entry
|
||||
verdict_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT verdict_hash_format CHECK (verdict_hash ~ '^[a-f0-9]{64}$'),
|
||||
CONSTRAINT prev_hash_format CHECK (prev_hash IS NULL OR prev_hash ~ '^[a-f0-9]{64}$'),
|
||||
CONSTRAINT policy_hash_format CHECK (policy_bundle_hash ~ '^[a-f0-9]{64}$')
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_bom_ref
|
||||
ON verdict_ledger (bom_ref);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_rekor_uuid
|
||||
ON verdict_ledger (rekor_uuid)
|
||||
WHERE rekor_uuid IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_created_at
|
||||
ON verdict_ledger (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_tenant_created
|
||||
ON verdict_ledger (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_prev_hash
|
||||
ON verdict_ledger (prev_hash)
|
||||
WHERE prev_hash IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_decision
|
||||
ON verdict_ledger (decision);
|
||||
|
||||
-- Composite index for chain walking
|
||||
CREATE INDEX IF NOT EXISTS idx_verdict_ledger_chain
|
||||
ON verdict_ledger (tenant_id, verdict_hash);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON TABLE verdict_ledger IS 'Append-only ledger of release verdicts with SHA-256 hash chaining for cryptographic audit trail';
|
||||
COMMENT ON COLUMN verdict_ledger.ledger_id IS 'Unique identifier for this ledger entry';
|
||||
COMMENT ON COLUMN verdict_ledger.bom_ref IS 'Package URL (purl) or container digest reference';
|
||||
COMMENT ON COLUMN verdict_ledger.cyclonedx_serial IS 'CycloneDX serialNumber URN linking to SBOM';
|
||||
COMMENT ON COLUMN verdict_ledger.rekor_uuid IS 'Transparency log entry UUID for external verification';
|
||||
COMMENT ON COLUMN verdict_ledger.decision IS 'The release decision: unknown, approve, reject, or pending';
|
||||
COMMENT ON COLUMN verdict_ledger.reason IS 'Human-readable explanation for the decision';
|
||||
COMMENT ON COLUMN verdict_ledger.policy_bundle_id IS 'Reference to the policy configuration used';
|
||||
COMMENT ON COLUMN verdict_ledger.policy_bundle_hash IS 'SHA-256 hash of the policy bundle for reproducibility';
|
||||
COMMENT ON COLUMN verdict_ledger.verifier_image_digest IS 'Container digest of the verifier service';
|
||||
COMMENT ON COLUMN verdict_ledger.signer_keyid IS 'Key ID that signed this verdict';
|
||||
COMMENT ON COLUMN verdict_ledger.prev_hash IS 'SHA-256 hash of previous entry (null for genesis)';
|
||||
COMMENT ON COLUMN verdict_ledger.verdict_hash IS 'SHA-256 hash of this entry canonical JSON form';
|
||||
COMMENT ON COLUMN verdict_ledger.created_at IS 'Timestamp when this verdict was recorded';
|
||||
COMMENT ON COLUMN verdict_ledger.tenant_id IS 'Tenant identifier for multi-tenancy';
|
||||
|
||||
-- Revoke UPDATE and DELETE for application role (append-only enforcement)
|
||||
-- This should be run after creating the appropriate role
|
||||
-- REVOKE UPDATE, DELETE ON verdict_ledger FROM stellaops_app;
|
||||
-- GRANT INSERT, SELECT ON verdict_ledger TO stellaops_app;
|
||||
@@ -0,0 +1,97 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IVerdictLedgerRepository.cs
|
||||
// Sprint: SPRINT_20260118_015_Attestor_verdict_ledger_foundation
|
||||
// Task: VL-002 - Implement VerdictLedger entity and repository
|
||||
// Description: Repository interface for append-only verdict ledger
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for append-only verdict ledger operations.
|
||||
/// Enforces hash chain integrity on append operations.
|
||||
/// </summary>
|
||||
public interface IVerdictLedgerRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Appends a new entry to the ledger.
|
||||
/// </summary>
|
||||
/// <param name="entry">The entry to append.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The appended entry with generated fields populated.</returns>
|
||||
/// <exception cref="ChainIntegrityException">
|
||||
/// Thrown if entry.PrevHash doesn't match the latest entry's VerdictHash.
|
||||
/// </exception>
|
||||
Task<VerdictLedgerEntry> AppendAsync(VerdictLedgerEntry entry, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an entry by its verdict hash.
|
||||
/// </summary>
|
||||
/// <param name="verdictHash">SHA-256 hash of the entry.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The entry if found, null otherwise.</returns>
|
||||
Task<VerdictLedgerEntry?> GetByHashAsync(string verdictHash, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all entries for a given bom-ref.
|
||||
/// </summary>
|
||||
/// <param name="bomRef">Package URL or container digest.</param>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Entries ordered by creation time (oldest first).</returns>
|
||||
Task<IReadOnlyList<VerdictLedgerEntry>> GetByBomRefAsync(
|
||||
string bomRef,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest entry for a tenant (tip of the chain).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The latest entry if any exist, null otherwise.</returns>
|
||||
Task<VerdictLedgerEntry?> GetLatestAsync(Guid tenantId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets entries in a hash range for chain verification.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant identifier.</param>
|
||||
/// <param name="fromHash">Starting hash (inclusive).</param>
|
||||
/// <param name="toHash">Ending hash (inclusive).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Entries in chain order from fromHash to toHash.</returns>
|
||||
Task<IReadOnlyList<VerdictLedgerEntry>> GetChainAsync(
|
||||
Guid tenantId,
|
||||
string fromHash,
|
||||
string toHash,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts total entries for a tenant.
|
||||
/// </summary>
|
||||
Task<long> CountAsync(Guid tenantId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when hash chain integrity is violated.
|
||||
/// </summary>
|
||||
public sealed class ChainIntegrityException : Exception
|
||||
{
|
||||
/// <summary>Expected previous hash.</summary>
|
||||
public string? ExpectedPrevHash { get; }
|
||||
|
||||
/// <summary>Actual previous hash provided.</summary>
|
||||
public string? ActualPrevHash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new chain integrity exception.
|
||||
/// </summary>
|
||||
public ChainIntegrityException(string? expected, string? actual)
|
||||
: base($"Chain integrity violation: expected prev_hash '{expected ?? "(genesis)"}' but got '{actual ?? "(genesis)"}'")
|
||||
{
|
||||
ExpectedPrevHash = expected;
|
||||
ActualPrevHash = actual;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Npgsql;
|
||||
using StellaOps.Attestor.Persistence.Entities;
|
||||
|
||||
namespace StellaOps.Attestor.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of the verdict ledger repository.
|
||||
/// Enforces append-only semantics with hash chain validation.
|
||||
/// </summary>
|
||||
public sealed class PostgresVerdictLedgerRepository : IVerdictLedgerRepository
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new PostgreSQL verdict ledger repository.
|
||||
/// </summary>
|
||||
public PostgresVerdictLedgerRepository(string connectionString)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VerdictLedgerEntry> AppendAsync(VerdictLedgerEntry entry, CancellationToken ct = default)
|
||||
{
|
||||
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;
|
||||
|
||||
if (entry.PrevHash != expectedPrevHash)
|
||||
{
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (await reader.ReadAsync(ct))
|
||||
{
|
||||
return entry with
|
||||
{
|
||||
LedgerId = reader.GetGuid(0),
|
||||
CreatedAt = reader.GetDateTime(1)
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Insert failed to return ledger_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VerdictLedgerEntry?> GetByHashAsync(string verdictHash, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VerdictLedgerEntry>> GetByBomRefAsync(
|
||||
string bomRef,
|
||||
Guid tenantId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VerdictLedgerEntry?> GetLatestAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VerdictLedgerEntry>> GetChainAsync(
|
||||
Guid tenantId,
|
||||
string fromHash,
|
||||
string toHash,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Walk backward from toHash to fromHash
|
||||
var chain = new List<VerdictLedgerEntry>();
|
||||
var currentHash = toHash;
|
||||
|
||||
while (!string.IsNullOrEmpty(currentHash))
|
||||
{
|
||||
var entry = await GetByHashAsync(currentHash, ct);
|
||||
if (entry == null || entry.TenantId != tenantId)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
chain.Add(entry);
|
||||
|
||||
if (currentHash == fromHash)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentHash = entry.PrevHash!;
|
||||
}
|
||||
|
||||
// Return in chain order (oldest to newest)
|
||||
chain.Reverse();
|
||||
return chain;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> CountAsync(Guid tenantId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(_connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static VerdictLedgerEntry MapToEntry(NpgsqlDataReader reader)
|
||||
{
|
||||
return new VerdictLedgerEntry
|
||||
{
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user