documentation cleanse, sprints work and planning. remaining non EF DAL migration to EF
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user