refactor(scheduler): move exception workers from web to worker side
- Remove ExceptionLifecycleWorker + ExpiringNotificationWorker from scheduler-web - Add both to AddSchedulerWorker() extension (worker-host already calls this) - Move PostgresExceptionRepository to Worker library - Web retains only SystemScheduleBootstrap (startup seed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,10 @@ app.MapAuditEndpoints();
|
|||||||
app.MapFirstSignalEndpoints();
|
app.MapFirstSignalEndpoints();
|
||||||
app.MapScriptsEndpoints();
|
app.MapScriptsEndpoints();
|
||||||
|
|
||||||
|
// Legacy /api/v1/jobengine/* compatibility endpoints
|
||||||
|
// (gateway still routes these paths here from the UI)
|
||||||
|
app.MapJobEngineLegacyEndpoints();
|
||||||
|
|
||||||
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
app.TryRefreshStellaRouterEndpoints(routerEnabled);
|
||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Persistence.Domain;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Persistence.Hashing;
|
||||||
|
using StellaOps.ReleaseOrchestrator.Persistence.Repositories;
|
||||||
|
|
||||||
|
namespace StellaOps.ReleaseOrchestrator.Persistence.Postgres;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PostgreSQL implementation of the audit repository for release-orchestrator.
|
||||||
|
/// Uses raw SQL (no EF Core) for a lean dependency footprint.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PostgresAuditRepository : IAuditRepository
|
||||||
|
{
|
||||||
|
private const string InsertEntrySql = """
|
||||||
|
INSERT INTO audit_entries (
|
||||||
|
entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
|
||||||
|
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
|
||||||
|
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata)
|
||||||
|
VALUES (
|
||||||
|
@entry_id, @tenant_id, @event_type, @resource_type, @resource_id, @actor_id, @actor_type,
|
||||||
|
@actor_ip, @user_agent, @http_method, @request_path, @old_state::jsonb, @new_state::jsonb, @description,
|
||||||
|
@correlation_id, @previous_entry_hash, @content_hash, @sequence_number, @occurred_at, @metadata::jsonb)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetSequenceSql = """
|
||||||
|
SELECT next_seq, prev_hash FROM next_audit_sequence(@tenant_id)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string UpdateSequenceHashSql = """
|
||||||
|
SELECT update_audit_sequence_hash(@tenant_id, @content_hash)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string VerifyChainSql = """
|
||||||
|
SELECT is_valid, invalid_entry_id, invalid_sequence, error_message
|
||||||
|
FROM verify_audit_chain(@tenant_id, @start_seq, @end_seq)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetSummarySql = """
|
||||||
|
SELECT total_entries, entries_since, event_types, unique_actors, unique_resources, earliest_entry, latest_entry
|
||||||
|
FROM get_audit_summary(@tenant_id, @since)
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetByIdSql = """
|
||||||
|
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
|
||||||
|
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
|
||||||
|
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
|
||||||
|
FROM audit_entries
|
||||||
|
WHERE tenant_id = @tenant_id AND entry_id = @entry_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string ListSql = """
|
||||||
|
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
|
||||||
|
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
|
||||||
|
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
|
||||||
|
FROM audit_entries
|
||||||
|
WHERE tenant_id = @tenant_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetLatestSql = """
|
||||||
|
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
|
||||||
|
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
|
||||||
|
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
|
||||||
|
FROM audit_entries
|
||||||
|
WHERE tenant_id = @tenant_id
|
||||||
|
ORDER BY sequence_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetBySequenceRangeSql = """
|
||||||
|
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
|
||||||
|
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
|
||||||
|
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
|
||||||
|
FROM audit_entries
|
||||||
|
WHERE tenant_id = @tenant_id AND sequence_number >= @start_seq AND sequence_number <= @end_seq
|
||||||
|
ORDER BY sequence_number ASC
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetByResourceSql = """
|
||||||
|
SELECT entry_id, tenant_id, event_type, resource_type, resource_id, actor_id, actor_type,
|
||||||
|
actor_ip, user_agent, http_method, request_path, old_state, new_state, description,
|
||||||
|
correlation_id, previous_entry_hash, content_hash, sequence_number, occurred_at, metadata
|
||||||
|
FROM audit_entries
|
||||||
|
WHERE tenant_id = @tenant_id AND resource_type = @resource_type AND resource_id = @resource_id
|
||||||
|
ORDER BY occurred_at DESC
|
||||||
|
LIMIT @limit
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string GetCountSql = """
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM audit_entries
|
||||||
|
WHERE tenant_id = @tenant_id
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly ReleaseOrchestratorDataSource _dataSource;
|
||||||
|
private readonly CanonicalJsonHasher _hasher;
|
||||||
|
private readonly ILogger<PostgresAuditRepository> _logger;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public PostgresAuditRepository(
|
||||||
|
ReleaseOrchestratorDataSource dataSource,
|
||||||
|
CanonicalJsonHasher hasher,
|
||||||
|
ILogger<PostgresAuditRepository> logger,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||||
|
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuditEntry> AppendAsync(
|
||||||
|
string tenantId,
|
||||||
|
AuditEventType eventType,
|
||||||
|
string resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
string actorId,
|
||||||
|
ActorType actorType,
|
||||||
|
string description,
|
||||||
|
string? oldState = null,
|
||||||
|
string? newState = null,
|
||||||
|
string? actorIp = null,
|
||||||
|
string? userAgent = null,
|
||||||
|
string? httpMethod = null,
|
||||||
|
string? requestPath = null,
|
||||||
|
string? correlationId = null,
|
||||||
|
string? metadata = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get next sequence number and previous hash
|
||||||
|
long sequenceNumber;
|
||||||
|
string? previousEntryHash;
|
||||||
|
|
||||||
|
await using (var seqCommand = new NpgsqlCommand(GetSequenceSql, connection, transaction))
|
||||||
|
{
|
||||||
|
seqCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
seqCommand.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
|
||||||
|
await using var reader = await seqCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Failed to get next audit sequence.");
|
||||||
|
}
|
||||||
|
|
||||||
|
sequenceNumber = reader.GetInt64(0);
|
||||||
|
previousEntryHash = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the entry
|
||||||
|
var occurredAt = _timeProvider.GetUtcNow();
|
||||||
|
var entry = AuditEntry.Create(
|
||||||
|
hasher: _hasher,
|
||||||
|
tenantId: tenantId,
|
||||||
|
eventType: eventType,
|
||||||
|
resourceType: resourceType,
|
||||||
|
resourceId: resourceId,
|
||||||
|
actorId: actorId,
|
||||||
|
actorType: actorType,
|
||||||
|
description: description,
|
||||||
|
occurredAt: occurredAt,
|
||||||
|
oldState: oldState,
|
||||||
|
newState: newState,
|
||||||
|
actorIp: actorIp,
|
||||||
|
userAgent: userAgent,
|
||||||
|
httpMethod: httpMethod,
|
||||||
|
requestPath: requestPath,
|
||||||
|
correlationId: correlationId,
|
||||||
|
previousEntryHash: previousEntryHash,
|
||||||
|
sequenceNumber: sequenceNumber,
|
||||||
|
metadata: metadata);
|
||||||
|
|
||||||
|
// Insert the entry
|
||||||
|
await using (var insertCommand = new NpgsqlCommand(InsertEntrySql, connection, transaction))
|
||||||
|
{
|
||||||
|
insertCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
AddEntryParameters(insertCommand, entry);
|
||||||
|
await insertCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sequence hash
|
||||||
|
await using (var updateCommand = new NpgsqlCommand(UpdateSequenceHashSql, connection, transaction))
|
||||||
|
{
|
||||||
|
updateCommand.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
updateCommand.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
updateCommand.Parameters.AddWithValue("content_hash", entry.ContentHash);
|
||||||
|
await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
_logger.LogDebug("Audit entry {EntryId} appended for tenant {TenantId}, sequence {Sequence}",
|
||||||
|
entry.EntryId, tenantId, sequenceNumber);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuditEntry?> GetByIdAsync(
|
||||||
|
string tenantId,
|
||||||
|
Guid entryId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = new NpgsqlCommand(GetByIdSql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
command.Parameters.AddWithValue("entry_id", entryId);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
|
||||||
|
? ReadEntry(reader)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AuditEntry>> ListAsync(
|
||||||
|
string tenantId,
|
||||||
|
AuditEventType? eventType = null,
|
||||||
|
string? resourceType = null,
|
||||||
|
Guid? resourceId = null,
|
||||||
|
string? actorId = null,
|
||||||
|
DateTimeOffset? startTime = null,
|
||||||
|
DateTimeOffset? endTime = null,
|
||||||
|
int limit = 100,
|
||||||
|
int offset = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var sql = ListSql;
|
||||||
|
var conditions = new List<string>();
|
||||||
|
var parameters = new List<NpgsqlParameter> { new("tenant_id", tenantId) };
|
||||||
|
|
||||||
|
if (eventType.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("event_type = @event_type");
|
||||||
|
parameters.Add(new NpgsqlParameter("event_type", (int)eventType.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceType is not null)
|
||||||
|
{
|
||||||
|
conditions.Add("resource_type = @resource_type");
|
||||||
|
parameters.Add(new NpgsqlParameter("resource_type", resourceType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("resource_id = @resource_id");
|
||||||
|
parameters.Add(new NpgsqlParameter("resource_id", resourceId.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actorId is not null)
|
||||||
|
{
|
||||||
|
conditions.Add("actor_id = @actor_id");
|
||||||
|
parameters.Add(new NpgsqlParameter("actor_id", actorId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("occurred_at >= @start_time");
|
||||||
|
parameters.Add(new NpgsqlParameter("start_time", startTime.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("occurred_at <= @end_time");
|
||||||
|
parameters.Add(new NpgsqlParameter("end_time", endTime.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.Count > 0)
|
||||||
|
{
|
||||||
|
sql += " AND " + string.Join(" AND ", conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY occurred_at DESC OFFSET @offset LIMIT @limit";
|
||||||
|
parameters.Add(new NpgsqlParameter("offset", offset));
|
||||||
|
parameters.Add(new NpgsqlParameter("limit", limit));
|
||||||
|
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddRange(parameters.ToArray());
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var entries = new List<AuditEntry>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
entries.Add(ReadEntry(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AuditEntry>> GetBySequenceRangeAsync(
|
||||||
|
string tenantId,
|
||||||
|
long startSequence,
|
||||||
|
long endSequence,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = new NpgsqlCommand(GetBySequenceRangeSql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
command.Parameters.AddWithValue("start_seq", startSequence);
|
||||||
|
command.Parameters.AddWithValue("end_seq", endSequence);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var entries = new List<AuditEntry>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
entries.Add(ReadEntry(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuditEntry?> GetLatestAsync(
|
||||||
|
string tenantId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = new NpgsqlCommand(GetLatestSql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
|
||||||
|
? ReadEntry(reader)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AuditEntry>> GetByResourceAsync(
|
||||||
|
string tenantId,
|
||||||
|
string resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
int limit = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = new NpgsqlCommand(GetByResourceSql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
command.Parameters.AddWithValue("resource_type", resourceType);
|
||||||
|
command.Parameters.AddWithValue("resource_id", resourceId);
|
||||||
|
command.Parameters.AddWithValue("limit", limit);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var entries = new List<AuditEntry>();
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
entries.Add(ReadEntry(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> GetCountAsync(
|
||||||
|
string tenantId,
|
||||||
|
AuditEventType? eventType = null,
|
||||||
|
DateTimeOffset? startTime = null,
|
||||||
|
DateTimeOffset? endTime = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var sql = GetCountSql;
|
||||||
|
var conditions = new List<string>();
|
||||||
|
var parameters = new List<NpgsqlParameter> { new("tenant_id", tenantId) };
|
||||||
|
|
||||||
|
if (eventType.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("event_type = @event_type");
|
||||||
|
parameters.Add(new NpgsqlParameter("event_type", (int)eventType.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("occurred_at >= @start_time");
|
||||||
|
parameters.Add(new NpgsqlParameter("start_time", startTime.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endTime.HasValue)
|
||||||
|
{
|
||||||
|
conditions.Add("occurred_at <= @end_time");
|
||||||
|
parameters.Add(new NpgsqlParameter("end_time", endTime.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.Count > 0)
|
||||||
|
{
|
||||||
|
sql += " AND " + string.Join(" AND ", conditions);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var command = new NpgsqlCommand(sql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddRange(parameters.ToArray());
|
||||||
|
|
||||||
|
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return result is long count ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChainVerificationResult> VerifyChainAsync(
|
||||||
|
string tenantId,
|
||||||
|
long? startSequence = null,
|
||||||
|
long? endSequence = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = new NpgsqlCommand(VerifyChainSql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
command.Parameters.AddWithValue("start_seq", (object?)startSequence ?? 1L);
|
||||||
|
command.Parameters.AddWithValue("end_seq", (object?)endSequence ?? DBNull.Value);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return new ChainVerificationResult(true, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChainVerificationResult(
|
||||||
|
IsValid: reader.GetBoolean(0),
|
||||||
|
InvalidEntryId: reader.IsDBNull(1) ? null : reader.GetGuid(1),
|
||||||
|
InvalidSequence: reader.IsDBNull(2) ? null : reader.GetInt64(2),
|
||||||
|
ErrorMessage: reader.IsDBNull(3) ? null : reader.GetString(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuditSummary> GetSummaryAsync(
|
||||||
|
string tenantId,
|
||||||
|
DateTimeOffset? since = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var command = new NpgsqlCommand(GetSummarySql, connection);
|
||||||
|
command.CommandTimeout = _dataSource.CommandTimeoutSeconds;
|
||||||
|
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||||
|
command.Parameters.AddWithValue("since", (object?)since ?? DBNull.Value);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return new AuditSummary(0, 0, 0, 0, 0, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AuditSummary(
|
||||||
|
TotalEntries: reader.GetInt64(0),
|
||||||
|
EntriesSince: reader.GetInt64(1),
|
||||||
|
EventTypes: reader.GetInt64(2),
|
||||||
|
UniqueActors: reader.GetInt64(3),
|
||||||
|
UniqueResources: reader.GetInt64(4),
|
||||||
|
EarliestEntry: reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
|
||||||
|
LatestEntry: reader.IsDBNull(6) ? null : reader.GetFieldValue<DateTimeOffset>(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEntryParameters(NpgsqlCommand command, AuditEntry entry)
|
||||||
|
{
|
||||||
|
command.Parameters.AddWithValue("entry_id", entry.EntryId);
|
||||||
|
command.Parameters.AddWithValue("tenant_id", entry.TenantId);
|
||||||
|
command.Parameters.AddWithValue("event_type", (int)entry.EventType);
|
||||||
|
command.Parameters.AddWithValue("resource_type", entry.ResourceType);
|
||||||
|
command.Parameters.AddWithValue("resource_id", entry.ResourceId);
|
||||||
|
command.Parameters.AddWithValue("actor_id", entry.ActorId);
|
||||||
|
command.Parameters.AddWithValue("actor_type", (int)entry.ActorType);
|
||||||
|
command.Parameters.AddWithValue("actor_ip", (object?)entry.ActorIp ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("user_agent", (object?)entry.UserAgent ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("http_method", (object?)entry.HttpMethod ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("request_path", (object?)entry.RequestPath ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("old_state", (object?)entry.OldState ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("new_state", (object?)entry.NewState ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("description", entry.Description);
|
||||||
|
command.Parameters.AddWithValue("correlation_id", (object?)entry.CorrelationId ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("previous_entry_hash", (object?)entry.PreviousEntryHash ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("content_hash", entry.ContentHash);
|
||||||
|
command.Parameters.AddWithValue("sequence_number", entry.SequenceNumber);
|
||||||
|
command.Parameters.AddWithValue("occurred_at", entry.OccurredAt);
|
||||||
|
command.Parameters.AddWithValue("metadata", (object?)entry.Metadata ?? DBNull.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEntry ReadEntry(NpgsqlDataReader reader) => new(
|
||||||
|
EntryId: reader.GetGuid(0),
|
||||||
|
TenantId: reader.GetString(1),
|
||||||
|
EventType: (AuditEventType)reader.GetInt32(2),
|
||||||
|
ResourceType: reader.GetString(3),
|
||||||
|
ResourceId: reader.GetGuid(4),
|
||||||
|
ActorId: reader.GetString(5),
|
||||||
|
ActorType: (ActorType)reader.GetInt32(6),
|
||||||
|
ActorIp: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
UserAgent: reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
HttpMethod: reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
RequestPath: reader.IsDBNull(10) ? null : reader.GetString(10),
|
||||||
|
OldState: reader.IsDBNull(11) ? null : reader.GetString(11),
|
||||||
|
NewState: reader.IsDBNull(12) ? null : reader.GetString(12),
|
||||||
|
Description: reader.GetString(13),
|
||||||
|
CorrelationId: reader.IsDBNull(14) ? null : reader.GetString(14),
|
||||||
|
PreviousEntryHash: reader.IsDBNull(15) ? null : reader.GetString(15),
|
||||||
|
ContentHash: reader.GetString(16),
|
||||||
|
SequenceNumber: reader.GetInt64(17),
|
||||||
|
OccurredAt: reader.GetFieldValue<DateTimeOffset>(18),
|
||||||
|
Metadata: reader.IsDBNull(19) ? null : reader.GetString(19));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user