documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF

This commit is contained in:
master
2026-02-25 01:24:07 +02:00
parent b07d27772e
commit 4db038123b
9090 changed files with 4836 additions and 2909 deletions

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
@@ -7,6 +8,8 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for policy exception operations.
/// Simple reads (GetById, GetByName, GetAll, Delete) use EF Core.
/// Complex writes/queries (Create, Update, Approve, Revoke, Expire, regex-based) use raw SQL.
/// </summary>
public sealed class ExceptionRepository : RepositoryBase<PolicyDataSource>, IExceptionRepository
{
@@ -48,35 +51,27 @@ public sealed class ExceptionRepository : RepositoryBase<PolicyDataSource>, IExc
/// <inheritdoc />
public async Task<ExceptionEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapException,
cancellationToken).ConfigureAwait(false);
return await dbContext.Exceptions
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Id == id, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ExceptionEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id AND name = @name";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapException,
cancellationToken).ConfigureAwait(false);
return await dbContext.Exceptions
.AsNoTracking()
.FirstOrDefaultAsync(e => e.TenantId == tenantId && e.Name == name, cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -87,30 +82,26 @@ public sealed class ExceptionRepository : RepositoryBase<PolicyDataSource>, IExc
int offset = 0,
CancellationToken cancellationToken = default)
{
var sql = "SELECT * FROM policy.exceptions WHERE tenant_id = @tenant_id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var q = dbContext.Exceptions
.AsNoTracking()
.Where(e => e.TenantId == tenantId);
if (status.HasValue)
{
sql += " AND status = @status";
q = q.Where(e => e.Status == status.Value);
}
sql += " ORDER BY name, id LIMIT @limit OFFSET @offset";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (status.HasValue)
{
AddParameter(cmd, "status", StatusToString(status.Value));
}
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapException,
cancellationToken).ConfigureAwait(false);
return await q
.OrderBy(e => e.Name)
.ThenBy(e => e.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -277,17 +268,14 @@ public sealed class ExceptionRepository : RepositoryBase<PolicyDataSource>, IExc
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM policy.exceptions WHERE tenant_id = @tenant_id AND id = @id";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
var rows = await dbContext.Exceptions
.Where(e => e.TenantId == tenantId && e.Id == id)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
return rows > 0;
}

View File

@@ -1,26 +1,26 @@
// -----------------------------------------------------------------------------
// GateDecisionHistoryRepository.cs
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
// Task: GR-005 - Add gate decision history endpoint
// Description: Repository for querying historical gate decisions
// Sprint: SPRINT_20260225_115_Policy_dal_ef_wrapper_removal_crud_migration
// Task: T1 - Migrate GateDecisionHistoryRepository to EF Core
// Description: Repository for querying historical gate decisions (EF Core)
// -----------------------------------------------------------------------------
using Npgsql;
using System.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for querying historical gate decisions.
/// Migrated from raw Npgsql to EF Core.
/// </summary>
public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryRepository
public sealed class GateDecisionHistoryRepository : RepositoryBase<PolicyDataSource>, IGateDecisionHistoryRepository
{
private readonly string _connectionString;
public GateDecisionHistoryRepository(string connectionString)
public GateDecisionHistoryRepository(PolicyDataSource dataSource, ILogger<GateDecisionHistoryRepository> logger)
: base(dataSource, logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}
/// <inheritdoc />
@@ -28,116 +28,52 @@ public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryReposito
GateDecisionHistoryQuery query,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
query.TenantId.ToString(), "reader", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
// Build query with filters
var sql = """
SELECT
decision_id,
bom_ref,
image_digest,
gate_status,
verdict_hash,
policy_bundle_id,
policy_bundle_hash,
evaluated_at,
ci_context,
actor,
blocking_unknown_ids,
warnings
FROM policy.gate_decisions
WHERE tenant_id = @tenant_id
""";
var parameters = new List<NpgsqlParameter>
{
new("tenant_id", query.TenantId)
};
var q = dbContext.GateDecisions
.AsNoTracking()
.Where(d => d.TenantId == query.TenantId);
if (!string.IsNullOrEmpty(query.GateId))
{
sql += " AND gate_id = @gate_id";
parameters.Add(new NpgsqlParameter("gate_id", query.GateId));
}
if (query.FromDate.HasValue)
{
sql += " AND evaluated_at >= @from_date";
parameters.Add(new NpgsqlParameter("from_date", query.FromDate.Value));
}
if (query.ToDate.HasValue)
{
sql += " AND evaluated_at <= @to_date";
parameters.Add(new NpgsqlParameter("to_date", query.ToDate.Value));
}
if (!string.IsNullOrEmpty(query.Status))
{
sql += " AND gate_status = @status";
parameters.Add(new NpgsqlParameter("status", query.Status));
}
if (!string.IsNullOrEmpty(query.Actor))
{
sql += " AND actor = @actor";
parameters.Add(new NpgsqlParameter("actor", query.Actor));
}
q = q.Where(d => d.GateId == query.GateId);
if (!string.IsNullOrEmpty(query.BomRef))
{
sql += " AND bom_ref = @bom_ref";
parameters.Add(new NpgsqlParameter("bom_ref", query.BomRef));
}
q = q.Where(d => d.BomRef == query.BomRef);
// Get total count first
var countSql = $"SELECT COUNT(*) FROM ({sql}) AS filtered";
await using var countCmd = new NpgsqlCommand(countSql, conn);
countCmd.Parameters.AddRange(parameters.ToArray());
var totalCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync(ct));
if (query.FromDate.HasValue)
q = q.Where(d => d.EvaluatedAt >= query.FromDate.Value);
// Apply pagination
sql += " ORDER BY evaluated_at DESC";
if (query.ToDate.HasValue)
q = q.Where(d => d.EvaluatedAt <= query.ToDate.Value);
if (!string.IsNullOrEmpty(query.Status))
q = q.Where(d => d.GateStatus == query.Status);
if (!string.IsNullOrEmpty(query.Actor))
q = q.Where(d => d.Actor == query.Actor);
var totalCount = await q.LongCountAsync(ct).ConfigureAwait(false);
var ordered = q.OrderByDescending(d => d.EvaluatedAt)
.ThenByDescending(d => d.DecisionId);
if (!string.IsNullOrEmpty(query.ContinuationToken))
{
var offset = DecodeContinuationToken(query.ContinuationToken);
sql += $" OFFSET {offset}";
ordered = (IOrderedQueryable<GateDecisionEntity>)ordered.Skip((int)offset);
}
sql += $" LIMIT {query.Limit + 1}"; // +1 to detect if there are more results
var entities = await ordered
.Take(query.Limit + 1)
.ToListAsync(ct)
.ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddRange(parameters.Select(p => p.Clone()).Cast<NpgsqlParameter>().ToArray());
var decisions = new List<GateDecisionRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
decisions.Add(new GateDecisionRecord
{
DecisionId = reader.GetGuid(0),
BomRef = reader.GetString(1),
ImageDigest = reader.IsDBNull(2) ? null : reader.GetString(2),
GateStatus = reader.GetString(3),
VerdictHash = reader.IsDBNull(4) ? null : reader.GetString(4),
PolicyBundleId = reader.IsDBNull(5) ? null : reader.GetString(5),
PolicyBundleHash = reader.IsDBNull(6) ? null : reader.GetString(6),
EvaluatedAt = reader.GetDateTime(7),
CiContext = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
BlockingUnknownIds = reader.IsDBNull(10) ? [] : ParseGuidArray(reader.GetString(10)),
Warnings = reader.IsDBNull(11) ? [] : ParseStringArray(reader.GetString(11))
});
}
// Check if there are more results
var hasMore = decisions.Count > query.Limit;
var hasMore = entities.Count > query.Limit;
if (hasMore)
{
decisions.RemoveAt(decisions.Count - 1);
entities.RemoveAt(entities.Count - 1);
}
string? nextToken = null;
@@ -151,7 +87,7 @@ public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryReposito
return new GateDecisionHistoryResult
{
Decisions = decisions,
Decisions = entities.Select(MapToRecord).ToList(),
Total = totalCount,
ContinuationToken = nextToken
};
@@ -163,90 +99,63 @@ public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryReposito
Guid tenantId,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
tenantId.ToString(), "reader", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
const string sql = """
SELECT
decision_id,
bom_ref,
image_digest,
gate_status,
verdict_hash,
policy_bundle_id,
policy_bundle_hash,
evaluated_at,
ci_context,
actor,
blocking_unknown_ids,
warnings
FROM policy.gate_decisions
WHERE decision_id = @decision_id AND tenant_id = @tenant_id
""";
var entity = await dbContext.GateDecisions
.AsNoTracking()
.FirstOrDefaultAsync(d => d.DecisionId == decisionId && d.TenantId == tenantId, ct)
.ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("decision_id", decisionId);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
{
return null;
}
return new GateDecisionRecord
{
DecisionId = reader.GetGuid(0),
BomRef = reader.GetString(1),
ImageDigest = reader.IsDBNull(2) ? null : reader.GetString(2),
GateStatus = reader.GetString(3),
VerdictHash = reader.IsDBNull(4) ? null : reader.GetString(4),
PolicyBundleId = reader.IsDBNull(5) ? null : reader.GetString(5),
PolicyBundleHash = reader.IsDBNull(6) ? null : reader.GetString(6),
EvaluatedAt = reader.GetDateTime(7),
CiContext = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
BlockingUnknownIds = reader.IsDBNull(10) ? [] : ParseGuidArray(reader.GetString(10)),
Warnings = reader.IsDBNull(11) ? [] : ParseStringArray(reader.GetString(11))
};
return entity is null ? null : MapToRecord(entity);
}
/// <inheritdoc />
public async Task RecordDecisionAsync(GateDecisionRecord decision, Guid tenantId, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
tenantId.ToString(), "writer", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
const string sql = """
INSERT INTO policy.gate_decisions (
decision_id, tenant_id, bom_ref, image_digest, gate_status, verdict_hash,
policy_bundle_id, policy_bundle_hash, evaluated_at, ci_context, actor,
blocking_unknown_ids, warnings
) VALUES (
@decision_id, @tenant_id, @bom_ref, @image_digest, @gate_status, @verdict_hash,
@policy_bundle_id, @policy_bundle_hash, @evaluated_at, @ci_context, @actor,
@blocking_unknown_ids, @warnings
)
""";
var entity = new GateDecisionEntity
{
DecisionId = decision.DecisionId,
TenantId = tenantId,
GateId = string.Empty, // gate_id not provided by GateDecisionRecord; matches original INSERT
BomRef = decision.BomRef,
ImageDigest = decision.ImageDigest,
GateStatus = decision.GateStatus,
VerdictHash = decision.VerdictHash,
PolicyBundleId = decision.PolicyBundleId,
PolicyBundleHash = decision.PolicyBundleHash,
EvaluatedAt = decision.EvaluatedAt,
CiContext = decision.CiContext,
Actor = decision.Actor,
BlockingUnknownIds = SerializeGuidArray(decision.BlockingUnknownIds),
Warnings = SerializeStringArray(decision.Warnings)
};
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("decision_id", decision.DecisionId);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("bom_ref", decision.BomRef);
cmd.Parameters.AddWithValue("image_digest", (object?)decision.ImageDigest ?? DBNull.Value);
cmd.Parameters.AddWithValue("gate_status", decision.GateStatus);
cmd.Parameters.AddWithValue("verdict_hash", (object?)decision.VerdictHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_id", (object?)decision.PolicyBundleId ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_hash", (object?)decision.PolicyBundleHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("evaluated_at", decision.EvaluatedAt);
cmd.Parameters.AddWithValue("ci_context", (object?)decision.CiContext ?? DBNull.Value);
cmd.Parameters.AddWithValue("actor", (object?)decision.Actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("blocking_unknown_ids", SerializeGuidArray(decision.BlockingUnknownIds));
cmd.Parameters.AddWithValue("warnings", SerializeStringArray(decision.Warnings));
await cmd.ExecuteNonQueryAsync(ct);
dbContext.GateDecisions.Add(entity);
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
private static GateDecisionRecord MapToRecord(GateDecisionEntity entity) => new()
{
DecisionId = entity.DecisionId,
BomRef = entity.BomRef,
ImageDigest = entity.ImageDigest,
GateStatus = entity.GateStatus,
VerdictHash = entity.VerdictHash,
PolicyBundleId = entity.PolicyBundleId,
PolicyBundleHash = entity.PolicyBundleHash,
EvaluatedAt = entity.EvaluatedAt.UtcDateTime,
CiContext = entity.CiContext,
Actor = entity.Actor,
BlockingUnknownIds = ParseGuidArray(entity.BlockingUnknownIds),
Warnings = ParseStringArray(entity.Warnings)
};
private static string EncodeContinuationToken(long offset) =>
Convert.ToBase64String(BitConverter.GetBytes(offset));
@@ -263,8 +172,9 @@ public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryReposito
}
}
private static List<Guid> ParseGuidArray(string json)
private static List<Guid> ParseGuidArray(string? json)
{
if (string.IsNullOrEmpty(json)) return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(json) ?? [];
@@ -275,8 +185,9 @@ public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryReposito
}
}
private static List<string> ParseStringArray(string json)
private static List<string> ParseStringArray(string? json)
{
if (string.IsNullOrEmpty(json)) return [];
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? [];

View File

@@ -1,184 +1,112 @@
// -----------------------------------------------------------------------------
// ReplayAuditRepository.cs
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
// Task: GR-007 - Create replay audit trail
// Description: Repository for recording and querying replay audit records
// Sprint: SPRINT_20260225_115_Policy_dal_ef_wrapper_removal_crud_migration
// Task: T2 - Migrate ReplayAuditRepository to EF Core
// Description: Repository for recording and querying replay audit records (EF Core)
// -----------------------------------------------------------------------------
using Npgsql;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for recording and querying replay audit records.
/// Migrated from raw Npgsql to EF Core.
/// </summary>
public sealed class ReplayAuditRepository : IReplayAuditRepository
public sealed class ReplayAuditRepository : RepositoryBase<PolicyDataSource>, IReplayAuditRepository
{
private readonly string _connectionString;
public ReplayAuditRepository(string connectionString)
public ReplayAuditRepository(PolicyDataSource dataSource, ILogger<ReplayAuditRepository> logger)
: base(dataSource, logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}
/// <inheritdoc />
public async Task RecordReplayAsync(ReplayAuditRecord record, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
record.TenantId.ToString(), "writer", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
const string sql = """
INSERT INTO policy.replay_audit (
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
match, original_hash, replayed_hash, mismatch_reason,
policy_bundle_id, policy_bundle_hash, verifier_digest,
duration_ms, actor, source, request_context
) VALUES (
@replay_id, @tenant_id, @bom_ref, @verdict_hash, @rekor_uuid, @replayed_at,
@match, @original_hash, @replayed_hash, @mismatch_reason,
@policy_bundle_id, @policy_bundle_hash, @verifier_digest,
@duration_ms, @actor, @source, @request_context::jsonb
)
""";
var entity = new ReplayAuditEntity
{
ReplayId = record.ReplayId,
TenantId = record.TenantId,
BomRef = record.BomRef,
VerdictHash = record.VerdictHash,
RekorUuid = record.RekorUuid,
ReplayedAt = record.ReplayedAt,
Match = record.Match,
OriginalHash = record.OriginalHash,
ReplayedHash = record.ReplayedHash,
MismatchReason = record.MismatchReason,
PolicyBundleId = record.PolicyBundleId,
PolicyBundleHash = record.PolicyBundleHash,
VerifierDigest = record.VerifierDigest,
DurationMs = record.DurationMs,
Actor = record.Actor,
Source = record.Source,
RequestContext = record.RequestContext
};
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("replay_id", record.ReplayId);
cmd.Parameters.AddWithValue("tenant_id", record.TenantId);
cmd.Parameters.AddWithValue("bom_ref", record.BomRef);
cmd.Parameters.AddWithValue("verdict_hash", record.VerdictHash);
cmd.Parameters.AddWithValue("rekor_uuid", (object?)record.RekorUuid ?? DBNull.Value);
cmd.Parameters.AddWithValue("replayed_at", record.ReplayedAt);
cmd.Parameters.AddWithValue("match", record.Match);
cmd.Parameters.AddWithValue("original_hash", (object?)record.OriginalHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("replayed_hash", (object?)record.ReplayedHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("mismatch_reason", (object?)record.MismatchReason ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_id", (object?)record.PolicyBundleId ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_hash", (object?)record.PolicyBundleHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("verifier_digest", (object?)record.VerifierDigest ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)record.DurationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("actor", (object?)record.Actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("source", (object?)record.Source ?? DBNull.Value);
cmd.Parameters.AddWithValue("request_context", (object?)record.RequestContext ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
dbContext.ReplayAudit.Add(entity);
await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<ReplayAuditResult> QueryAsync(ReplayAuditQuery query, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
query.TenantId.ToString(), "reader", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var sql = """
SELECT
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
match, original_hash, replayed_hash, mismatch_reason,
policy_bundle_id, policy_bundle_hash, verifier_digest,
duration_ms, actor, source, request_context
FROM policy.replay_audit
WHERE tenant_id = @tenant_id
""";
var parameters = new List<NpgsqlParameter>
{
new("tenant_id", query.TenantId)
};
var q = dbContext.ReplayAudit
.AsNoTracking()
.Where(r => r.TenantId == query.TenantId);
if (!string.IsNullOrEmpty(query.BomRef))
{
sql += " AND bom_ref = @bom_ref";
parameters.Add(new NpgsqlParameter("bom_ref", query.BomRef));
}
q = q.Where(r => r.BomRef == query.BomRef);
if (!string.IsNullOrEmpty(query.VerdictHash))
{
sql += " AND verdict_hash = @verdict_hash";
parameters.Add(new NpgsqlParameter("verdict_hash", query.VerdictHash));
}
q = q.Where(r => r.VerdictHash == query.VerdictHash);
if (!string.IsNullOrEmpty(query.RekorUuid))
{
sql += " AND rekor_uuid = @rekor_uuid";
parameters.Add(new NpgsqlParameter("rekor_uuid", query.RekorUuid));
}
q = q.Where(r => r.RekorUuid == query.RekorUuid);
if (query.FromDate.HasValue)
{
sql += " AND replayed_at >= @from_date";
parameters.Add(new NpgsqlParameter("from_date", query.FromDate.Value));
}
q = q.Where(r => r.ReplayedAt >= query.FromDate.Value);
if (query.ToDate.HasValue)
{
sql += " AND replayed_at <= @to_date";
parameters.Add(new NpgsqlParameter("to_date", query.ToDate.Value));
}
q = q.Where(r => r.ReplayedAt <= query.ToDate.Value);
if (query.MatchOnly.HasValue)
{
sql += " AND match = @match";
parameters.Add(new NpgsqlParameter("match", query.MatchOnly.Value));
}
q = q.Where(r => r.Match == query.MatchOnly.Value);
if (!string.IsNullOrEmpty(query.Actor))
{
sql += " AND actor = @actor";
parameters.Add(new NpgsqlParameter("actor", query.Actor));
}
q = q.Where(r => r.Actor == query.Actor);
// Get total count
var countSql = $"SELECT COUNT(*) FROM ({sql}) AS filtered";
await using var countCmd = new NpgsqlCommand(countSql, conn);
countCmd.Parameters.AddRange(parameters.ToArray());
var totalCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync(ct));
var totalCount = await q.LongCountAsync(ct).ConfigureAwait(false);
// Apply pagination
sql += " ORDER BY replayed_at DESC";
var ordered = q.OrderByDescending(r => r.ReplayedAt)
.ThenByDescending(r => r.ReplayId);
if (!string.IsNullOrEmpty(query.ContinuationToken))
{
var offset = DecodeContinuationToken(query.ContinuationToken);
sql += $" OFFSET {offset}";
ordered = (IOrderedQueryable<ReplayAuditEntity>)ordered.Skip((int)offset);
}
sql += $" LIMIT {query.Limit + 1}";
var entities = await ordered
.Take(query.Limit + 1)
.ToListAsync(ct)
.ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddRange(parameters.Select(p => p.Clone()).Cast<NpgsqlParameter>().ToArray());
var records = new List<ReplayAuditRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
records.Add(new ReplayAuditRecord
{
ReplayId = reader.GetGuid(0),
TenantId = reader.GetGuid(1),
BomRef = reader.GetString(2),
VerdictHash = reader.GetString(3),
RekorUuid = reader.IsDBNull(4) ? null : reader.GetString(4),
ReplayedAt = reader.GetDateTime(5),
Match = reader.GetBoolean(6),
OriginalHash = reader.IsDBNull(7) ? null : reader.GetString(7),
ReplayedHash = reader.IsDBNull(8) ? null : reader.GetString(8),
MismatchReason = reader.IsDBNull(9) ? null : reader.GetString(9),
PolicyBundleId = reader.IsDBNull(10) ? null : reader.GetString(10),
PolicyBundleHash = reader.IsDBNull(11) ? null : reader.GetString(11),
VerifierDigest = reader.IsDBNull(12) ? null : reader.GetString(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
Actor = reader.IsDBNull(14) ? null : reader.GetString(14),
Source = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestContext = reader.IsDBNull(16) ? null : reader.GetString(16)
});
}
var hasMore = records.Count > query.Limit;
var hasMore = entities.Count > query.Limit;
if (hasMore)
{
records.RemoveAt(records.Count - 1);
entities.RemoveAt(entities.Count - 1);
}
string? nextToken = null;
@@ -192,7 +120,7 @@ public sealed class ReplayAuditRepository : IReplayAuditRepository
return new ReplayAuditResult
{
Records = records,
Records = entities.Select(MapToRecord).ToList(),
Total = totalCount,
ContinuationToken = nextToken
};
@@ -204,49 +132,16 @@ public sealed class ReplayAuditRepository : IReplayAuditRepository
Guid tenantId,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
tenantId.ToString(), "reader", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
const string sql = """
SELECT
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
match, original_hash, replayed_hash, mismatch_reason,
policy_bundle_id, policy_bundle_hash, verifier_digest,
duration_ms, actor, source, request_context
FROM policy.replay_audit
WHERE replay_id = @replay_id AND tenant_id = @tenant_id
""";
var entity = await dbContext.ReplayAudit
.AsNoTracking()
.FirstOrDefaultAsync(r => r.ReplayId == replayId && r.TenantId == tenantId, ct)
.ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("replay_id", replayId);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
{
return null;
}
return new ReplayAuditRecord
{
ReplayId = reader.GetGuid(0),
TenantId = reader.GetGuid(1),
BomRef = reader.GetString(2),
VerdictHash = reader.GetString(3),
RekorUuid = reader.IsDBNull(4) ? null : reader.GetString(4),
ReplayedAt = reader.GetDateTime(5),
Match = reader.GetBoolean(6),
OriginalHash = reader.IsDBNull(7) ? null : reader.GetString(7),
ReplayedHash = reader.IsDBNull(8) ? null : reader.GetString(8),
MismatchReason = reader.IsDBNull(9) ? null : reader.GetString(9),
PolicyBundleId = reader.IsDBNull(10) ? null : reader.GetString(10),
PolicyBundleHash = reader.IsDBNull(11) ? null : reader.GetString(11),
VerifierDigest = reader.IsDBNull(12) ? null : reader.GetString(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
Actor = reader.IsDBNull(14) ? null : reader.GetString(14),
Source = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestContext = reader.IsDBNull(16) ? null : reader.GetString(16)
};
return entity is null ? null : MapToRecord(entity);
}
/// <inheritdoc />
@@ -256,47 +151,31 @@ public sealed class ReplayAuditRepository : IReplayAuditRepository
DateTimeOffset? toDate,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var connection = await DataSource.OpenConnectionAsync(
tenantId.ToString(), "reader", ct).ConfigureAwait(false);
await using var dbContext = PolicyDbContextFactory.Create(connection, CommandTimeoutSeconds, DataSource.SchemaName);
var sql = """
SELECT
COUNT(*) AS total_attempts,
COUNT(*) FILTER (WHERE match = true) AS successful_matches,
COUNT(*) FILTER (WHERE match = false) AS mismatches,
AVG(duration_ms) AS avg_duration_ms
FROM policy.replay_audit
WHERE tenant_id = @tenant_id
""";
var parameters = new List<NpgsqlParameter>
{
new("tenant_id", tenantId)
};
var q = dbContext.ReplayAudit
.AsNoTracking()
.Where(r => r.TenantId == tenantId);
if (fromDate.HasValue)
{
sql += " AND replayed_at >= @from_date";
parameters.Add(new NpgsqlParameter("from_date", fromDate.Value));
}
q = q.Where(r => r.ReplayedAt >= fromDate.Value);
if (toDate.HasValue)
q = q.Where(r => r.ReplayedAt <= toDate.Value);
var totalAttempts = await q.LongCountAsync(ct).ConfigureAwait(false);
var successfulMatches = await q.LongCountAsync(r => r.Match, ct).ConfigureAwait(false);
var mismatches = await q.LongCountAsync(r => !r.Match, ct).ConfigureAwait(false);
double? avgDurationMs = null;
var withDuration = q.Where(r => r.DurationMs != null);
if (await withDuration.AnyAsync(ct).ConfigureAwait(false))
{
sql += " AND replayed_at <= @to_date";
parameters.Add(new NpgsqlParameter("to_date", toDate.Value));
avgDurationMs = await withDuration.AverageAsync(r => (double)r.DurationMs!, ct).ConfigureAwait(false);
}
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddRange(parameters.ToArray());
await using var reader = await cmd.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
var totalAttempts = reader.GetInt64(0);
var successfulMatches = reader.GetInt64(1);
var mismatches = reader.GetInt64(2);
var avgDurationMs = reader.IsDBNull(3) ? null : (double?)reader.GetDouble(3);
return new ReplayMetrics
{
TotalAttempts = totalAttempts,
@@ -307,6 +186,27 @@ public sealed class ReplayAuditRepository : IReplayAuditRepository
};
}
private static ReplayAuditRecord MapToRecord(ReplayAuditEntity entity) => new()
{
ReplayId = entity.ReplayId,
TenantId = entity.TenantId,
BomRef = entity.BomRef,
VerdictHash = entity.VerdictHash,
RekorUuid = entity.RekorUuid,
ReplayedAt = entity.ReplayedAt.UtcDateTime,
Match = entity.Match,
OriginalHash = entity.OriginalHash,
ReplayedHash = entity.ReplayedHash,
MismatchReason = entity.MismatchReason,
PolicyBundleId = entity.PolicyBundleId,
PolicyBundleHash = entity.PolicyBundleHash,
VerifierDigest = entity.VerifierDigest,
DurationMs = entity.DurationMs,
Actor = entity.Actor,
Source = entity.Source,
RequestContext = entity.RequestContext
};
private static string EncodeContinuationToken(long offset) =>
Convert.ToBase64String(BitConverter.GetBytes(offset));