Add unit tests for ExceptionEvaluator, ExceptionEvent, ExceptionHistory, and ExceptionObject models
- Implemented comprehensive unit tests for the ExceptionEvaluator service, covering various scenarios including matching exceptions, environment checks, and evidence references. - Created tests for the ExceptionEvent model to validate event creation methods and ensure correct event properties. - Developed tests for the ExceptionHistory model to verify event count, order, and timestamps. - Added tests for the ExceptionObject domain model to ensure validity checks and property preservation for various fields.
This commit is contained in:
@@ -0,0 +1,795 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL repository implementation for auditable exception objects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implements the new IExceptionRepository interface from Policy.Exceptions
|
||||
/// with full audit trail support via exception_events table.
|
||||
/// </remarks>
|
||||
public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDataSource>, IAuditableExceptionRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new exception object repository.
|
||||
/// </summary>
|
||||
public PostgresExceptionObjectRepository(PolicyDataSource dataSource, ILogger<PostgresExceptionObjectRepository> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionObject> CreateAsync(
|
||||
ExceptionObject exception,
|
||||
string actorId,
|
||||
string? clientInfo = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (exception.Version != 1)
|
||||
{
|
||||
throw new ArgumentException("New exception must have Version = 1", nameof(exception));
|
||||
}
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
exception.Scope.TenantId?.ToString() ?? "default", "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Insert exception
|
||||
const string insertSql = """
|
||||
INSERT INTO policy.exceptions (
|
||||
exception_id, version, status, type,
|
||||
artifact_digest, purl_pattern, vulnerability_id, policy_rule_id,
|
||||
environments, tenant_id,
|
||||
owner_id, requester_id, approver_ids,
|
||||
created_at, updated_at, approved_at, expires_at,
|
||||
reason_code, rationale, evidence_refs, compensating_controls,
|
||||
metadata, ticket_ref
|
||||
)
|
||||
VALUES (
|
||||
@exception_id, @version, @status, @type,
|
||||
@artifact_digest, @purl_pattern, @vulnerability_id, @policy_rule_id,
|
||||
@environments, @tenant_id,
|
||||
@owner_id, @requester_id, @approver_ids,
|
||||
@created_at, @updated_at, @approved_at, @expires_at,
|
||||
@reason_code, @rationale, @evidence_refs::jsonb, @compensating_controls::jsonb,
|
||||
@metadata::jsonb, @ticket_ref
|
||||
)
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
await using var insertCommand = new NpgsqlCommand(insertSql, connection, transaction);
|
||||
AddExceptionParameters(insertCommand, exception);
|
||||
|
||||
await insertCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Insert created event
|
||||
var createdEvent = ExceptionEvent.ForCreated(
|
||||
exception.ExceptionId,
|
||||
actorId,
|
||||
$"Exception created: {exception.Type} for {GetScopeDescription(exception.Scope)}",
|
||||
clientInfo);
|
||||
|
||||
await InsertEventAsync(connection, transaction, createdEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Logger.LogInformation(
|
||||
"Created exception {ExceptionId} of type {Type} by {Actor}",
|
||||
exception.ExceptionId, exception.Type, actorId);
|
||||
|
||||
return exception;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionObject> UpdateAsync(
|
||||
ExceptionObject exception,
|
||||
ExceptionEventType eventType,
|
||||
string actorId,
|
||||
string? description = null,
|
||||
string? clientInfo = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
exception.Scope.TenantId?.ToString() ?? "default", "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Get current version for optimistic concurrency
|
||||
const string versionCheckSql = """
|
||||
SELECT version, status FROM policy.exceptions
|
||||
WHERE exception_id = @exception_id
|
||||
FOR UPDATE
|
||||
""";
|
||||
|
||||
await using var versionCommand = new NpgsqlCommand(versionCheckSql, connection, transaction);
|
||||
AddParameter(versionCommand, "exception_id", exception.ExceptionId);
|
||||
|
||||
await using var reader = await versionCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
throw new InvalidOperationException($"Exception {exception.ExceptionId} not found");
|
||||
}
|
||||
|
||||
var currentVersion = reader.GetInt32(0);
|
||||
var currentStatus = ParseStatus(reader.GetString(1));
|
||||
await reader.CloseAsync().ConfigureAwait(false);
|
||||
|
||||
if (currentVersion != exception.Version - 1)
|
||||
{
|
||||
throw new ConcurrencyException(exception.ExceptionId, exception.Version - 1, currentVersion);
|
||||
}
|
||||
|
||||
// Update exception
|
||||
const string updateSql = """
|
||||
UPDATE policy.exceptions SET
|
||||
version = @version,
|
||||
status = @status,
|
||||
updated_at = @updated_at,
|
||||
approved_at = @approved_at,
|
||||
approver_ids = @approver_ids,
|
||||
expires_at = @expires_at,
|
||||
evidence_refs = @evidence_refs::jsonb,
|
||||
compensating_controls = @compensating_controls::jsonb,
|
||||
metadata = @metadata::jsonb,
|
||||
ticket_ref = @ticket_ref
|
||||
WHERE exception_id = @exception_id AND version = @current_version
|
||||
""";
|
||||
|
||||
await using var updateCommand = new NpgsqlCommand(updateSql, connection, transaction);
|
||||
AddParameter(updateCommand, "exception_id", exception.ExceptionId);
|
||||
AddParameter(updateCommand, "version", exception.Version);
|
||||
AddParameter(updateCommand, "status", StatusToString(exception.Status));
|
||||
AddParameter(updateCommand, "updated_at", exception.UpdatedAt);
|
||||
AddParameter(updateCommand, "approved_at", (object?)exception.ApprovedAt ?? DBNull.Value);
|
||||
AddTextArrayParameter(updateCommand, "approver_ids", exception.ApproverIds.ToArray());
|
||||
AddParameter(updateCommand, "expires_at", exception.ExpiresAt);
|
||||
AddJsonbParameter(updateCommand, "evidence_refs", JsonSerializer.Serialize(exception.EvidenceRefs, JsonOptions));
|
||||
AddJsonbParameter(updateCommand, "compensating_controls", JsonSerializer.Serialize(exception.CompensatingControls, JsonOptions));
|
||||
AddJsonbParameter(updateCommand, "metadata", JsonSerializer.Serialize(exception.Metadata, JsonOptions));
|
||||
AddParameter(updateCommand, "ticket_ref", (object?)exception.TicketRef ?? DBNull.Value);
|
||||
AddParameter(updateCommand, "current_version", currentVersion);
|
||||
|
||||
var rows = await updateCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (rows == 0)
|
||||
{
|
||||
throw new ConcurrencyException(exception.ExceptionId, currentVersion, -1);
|
||||
}
|
||||
|
||||
// Get sequence number for event
|
||||
var sequenceNumber = await GetNextSequenceNumberAsync(connection, transaction, exception.ExceptionId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Insert event
|
||||
var updateEvent = new ExceptionEvent
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
ExceptionId = exception.ExceptionId,
|
||||
SequenceNumber = sequenceNumber,
|
||||
EventType = eventType,
|
||||
ActorId = actorId,
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
PreviousStatus = currentStatus,
|
||||
NewStatus = exception.Status,
|
||||
NewVersion = exception.Version,
|
||||
Description = description ?? $"{eventType} by {actorId}",
|
||||
ClientInfo = clientInfo
|
||||
};
|
||||
|
||||
await InsertEventAsync(connection, transaction, updateEvent, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Logger.LogInformation(
|
||||
"Updated exception {ExceptionId} to version {Version}, event {EventType} by {Actor}",
|
||||
exception.ExceptionId, exception.Version, eventType, actorId);
|
||||
|
||||
return exception;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionObject?> GetByIdAsync(
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = "SELECT * FROM policy.exceptions WHERE exception_id = @exception_id";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
"default",
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "exception_id", exceptionId),
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionObject>> GetByFilterAsync(
|
||||
ExceptionFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (whereClause, parameters) = BuildFilterWhereClause(filter);
|
||||
var sql = $"""
|
||||
SELECT * FROM policy.exceptions
|
||||
{whereClause}
|
||||
ORDER BY created_at DESC, exception_id
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
filter.TenantId?.ToString() ?? "default",
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
AddParameter(cmd, name, value);
|
||||
}
|
||||
AddParameter(cmd, "limit", filter.Limit);
|
||||
AddParameter(cmd, "offset", filter.Offset);
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionObject>> GetActiveByScopeAsync(
|
||||
ExceptionScope scope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Build dynamic query for scope matching
|
||||
// Using OR logic: exception applies if ANY of its scope fields match
|
||||
var conditions = new List<string>();
|
||||
var parameters = new List<(string name, object value)>();
|
||||
|
||||
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
|
||||
{
|
||||
conditions.Add("(artifact_digest IS NULL OR artifact_digest = @artifact_digest)");
|
||||
parameters.Add(("artifact_digest", scope.ArtifactDigest));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(scope.VulnerabilityId))
|
||||
{
|
||||
conditions.Add("(vulnerability_id IS NULL OR vulnerability_id = @vulnerability_id)");
|
||||
parameters.Add(("vulnerability_id", scope.VulnerabilityId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(scope.PurlPattern))
|
||||
{
|
||||
// For PURL matching, we need to check if the exception's pattern matches the given PURL
|
||||
// Exception patterns can have wildcards like pkg:npm/lodash@*
|
||||
conditions.Add("(purl_pattern IS NULL OR @purl LIKE REPLACE(REPLACE(purl_pattern, '*', '%'), '?', '_'))");
|
||||
parameters.Add(("purl", scope.PurlPattern));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(scope.PolicyRuleId))
|
||||
{
|
||||
conditions.Add("(policy_rule_id IS NULL OR policy_rule_id = @policy_rule_id)");
|
||||
parameters.Add(("policy_rule_id", scope.PolicyRuleId));
|
||||
}
|
||||
|
||||
var scopeCondition = conditions.Count > 0
|
||||
? $"AND ({string.Join(" AND ", conditions)})"
|
||||
: "";
|
||||
|
||||
var sql = $"""
|
||||
SELECT * FROM policy.exceptions
|
||||
WHERE status = 'active'
|
||||
AND expires_at > NOW()
|
||||
{scopeCondition}
|
||||
ORDER BY created_at DESC, exception_id
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
scope.TenantId?.ToString() ?? "default",
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
AddParameter(cmd, name, value);
|
||||
}
|
||||
},
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionObject>> GetExpiringAsync(
|
||||
TimeSpan horizon,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exceptions
|
||||
WHERE status = 'active'
|
||||
AND expires_at > NOW()
|
||||
AND expires_at <= NOW() + @horizon
|
||||
ORDER BY expires_at ASC, exception_id
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
"default",
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "horizon", horizon),
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ExceptionObject>> GetExpiredActiveAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exceptions
|
||||
WHERE status = 'active'
|
||||
AND expires_at <= NOW()
|
||||
ORDER BY expires_at ASC, exception_id
|
||||
""";
|
||||
|
||||
return await QueryAsync(
|
||||
"default",
|
||||
sql,
|
||||
null,
|
||||
MapException,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionHistory> GetHistoryAsync(
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT * FROM policy.exception_events
|
||||
WHERE exception_id = @exception_id
|
||||
ORDER BY sequence_number ASC
|
||||
""";
|
||||
|
||||
var events = await QueryAsync(
|
||||
"default",
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "exception_id", exceptionId),
|
||||
MapEvent,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ExceptionHistory
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Events = events.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ExceptionCounts> GetCountsAsync(
|
||||
Guid? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantCondition = tenantId.HasValue
|
||||
? "WHERE tenant_id = @tenant_id"
|
||||
: "";
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status = 'proposed') AS proposed,
|
||||
COUNT(*) FILTER (WHERE status = 'approved') AS approved,
|
||||
COUNT(*) FILTER (WHERE status = 'active') AS active,
|
||||
COUNT(*) FILTER (WHERE status = 'expired') AS expired,
|
||||
COUNT(*) FILTER (WHERE status = 'revoked') AS revoked,
|
||||
COUNT(*) FILTER (WHERE status = 'active' AND expires_at <= NOW() + INTERVAL '7 days') AS expiring_soon
|
||||
FROM policy.exceptions
|
||||
{tenantCondition}
|
||||
""";
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
tenantId?.ToString() ?? "default", "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var command = CreateCommand(sql, connection);
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId.Value);
|
||||
}
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new ExceptionCounts
|
||||
{
|
||||
Total = reader.GetInt32(reader.GetOrdinal("total")),
|
||||
Proposed = reader.GetInt32(reader.GetOrdinal("proposed")),
|
||||
Approved = reader.GetInt32(reader.GetOrdinal("approved")),
|
||||
Active = reader.GetInt32(reader.GetOrdinal("active")),
|
||||
Expired = reader.GetInt32(reader.GetOrdinal("expired")),
|
||||
Revoked = reader.GetInt32(reader.GetOrdinal("revoked")),
|
||||
ExpiringSoon = reader.GetInt32(reader.GetOrdinal("expiring_soon"))
|
||||
};
|
||||
}
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private void AddExceptionParameters(NpgsqlCommand command, ExceptionObject exception)
|
||||
{
|
||||
AddParameter(command, "exception_id", exception.ExceptionId);
|
||||
AddParameter(command, "version", exception.Version);
|
||||
AddParameter(command, "status", StatusToString(exception.Status));
|
||||
AddParameter(command, "type", TypeToString(exception.Type));
|
||||
AddParameter(command, "artifact_digest", (object?)exception.Scope.ArtifactDigest ?? DBNull.Value);
|
||||
AddParameter(command, "purl_pattern", (object?)exception.Scope.PurlPattern ?? DBNull.Value);
|
||||
AddParameter(command, "vulnerability_id", (object?)exception.Scope.VulnerabilityId ?? DBNull.Value);
|
||||
AddParameter(command, "policy_rule_id", (object?)exception.Scope.PolicyRuleId ?? DBNull.Value);
|
||||
AddTextArrayParameter(command, "environments", exception.Scope.Environments.ToArray());
|
||||
AddParameter(command, "tenant_id", (object?)exception.Scope.TenantId ?? DBNull.Value);
|
||||
AddParameter(command, "owner_id", exception.OwnerId);
|
||||
AddParameter(command, "requester_id", exception.RequesterId);
|
||||
AddTextArrayParameter(command, "approver_ids", exception.ApproverIds.ToArray());
|
||||
AddParameter(command, "created_at", exception.CreatedAt);
|
||||
AddParameter(command, "updated_at", exception.UpdatedAt);
|
||||
AddParameter(command, "approved_at", (object?)exception.ApprovedAt ?? DBNull.Value);
|
||||
AddParameter(command, "expires_at", exception.ExpiresAt);
|
||||
AddParameter(command, "reason_code", ReasonToString(exception.ReasonCode));
|
||||
AddParameter(command, "rationale", exception.Rationale);
|
||||
AddJsonbParameter(command, "evidence_refs", JsonSerializer.Serialize(exception.EvidenceRefs, JsonOptions));
|
||||
AddJsonbParameter(command, "compensating_controls", JsonSerializer.Serialize(exception.CompensatingControls, JsonOptions));
|
||||
AddJsonbParameter(command, "metadata", JsonSerializer.Serialize(exception.Metadata, JsonOptions));
|
||||
AddParameter(command, "ticket_ref", (object?)exception.TicketRef ?? DBNull.Value);
|
||||
}
|
||||
|
||||
private async Task InsertEventAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
ExceptionEvent evt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO policy.exception_events (
|
||||
id, exception_id, sequence_number, event_type, actor_id,
|
||||
occurred_at, previous_status, new_status, new_version,
|
||||
description, details, client_info
|
||||
)
|
||||
VALUES (
|
||||
@id, @exception_id, @sequence_number, @event_type, @actor_id,
|
||||
@occurred_at, @previous_status, @new_status, @new_version,
|
||||
@description, @details::jsonb, @client_info
|
||||
)
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
AddParameter(command, "id", evt.EventId);
|
||||
AddParameter(command, "exception_id", evt.ExceptionId);
|
||||
AddParameter(command, "sequence_number", evt.SequenceNumber);
|
||||
AddParameter(command, "event_type", EventTypeToString(evt.EventType));
|
||||
AddParameter(command, "actor_id", evt.ActorId);
|
||||
AddParameter(command, "occurred_at", evt.OccurredAt);
|
||||
AddParameter(command, "previous_status", (object?)StatusToStringNullable(evt.PreviousStatus) ?? DBNull.Value);
|
||||
AddParameter(command, "new_status", StatusToString(evt.NewStatus));
|
||||
AddParameter(command, "new_version", evt.NewVersion);
|
||||
AddParameter(command, "description", (object?)evt.Description ?? DBNull.Value);
|
||||
AddJsonbParameter(command, "details", JsonSerializer.Serialize(evt.Details, JsonOptions));
|
||||
AddParameter(command, "client_info", (object?)evt.ClientInfo ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<int> GetNextSequenceNumberAsync(
|
||||
NpgsqlConnection connection,
|
||||
NpgsqlTransaction transaction,
|
||||
string exceptionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COALESCE(MAX(sequence_number), 0) + 1
|
||||
FROM policy.exception_events
|
||||
WHERE exception_id = @exception_id
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection, transaction);
|
||||
AddParameter(command, "exception_id", exceptionId);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Convert.ToInt32(result);
|
||||
}
|
||||
|
||||
private static (string whereClause, List<(string name, object value)> parameters) BuildFilterWhereClause(ExceptionFilter filter)
|
||||
{
|
||||
var conditions = new List<string>();
|
||||
var parameters = new List<(string name, object value)>();
|
||||
|
||||
if (filter.Status.HasValue)
|
||||
{
|
||||
conditions.Add("status = @status");
|
||||
parameters.Add(("status", StatusToString(filter.Status.Value)));
|
||||
}
|
||||
|
||||
if (filter.Type.HasValue)
|
||||
{
|
||||
conditions.Add("type = @type");
|
||||
parameters.Add(("type", TypeToString(filter.Type.Value)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.VulnerabilityId))
|
||||
{
|
||||
conditions.Add("vulnerability_id = @vulnerability_id");
|
||||
parameters.Add(("vulnerability_id", filter.VulnerabilityId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.PurlPattern))
|
||||
{
|
||||
conditions.Add("purl_pattern LIKE @purl_pattern");
|
||||
parameters.Add(("purl_pattern", $"%{filter.PurlPattern}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.Environment))
|
||||
{
|
||||
conditions.Add("@environment = ANY(environments)");
|
||||
parameters.Add(("environment", filter.Environment));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.OwnerId))
|
||||
{
|
||||
conditions.Add("owner_id = @owner_id");
|
||||
parameters.Add(("owner_id", filter.OwnerId));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.RequesterId))
|
||||
{
|
||||
conditions.Add("requester_id = @requester_id");
|
||||
parameters.Add(("requester_id", filter.RequesterId));
|
||||
}
|
||||
|
||||
if (filter.TenantId.HasValue)
|
||||
{
|
||||
conditions.Add("tenant_id = @tenant_id");
|
||||
parameters.Add(("tenant_id", filter.TenantId.Value));
|
||||
}
|
||||
|
||||
if (filter.CreatedAfter.HasValue)
|
||||
{
|
||||
conditions.Add("created_at > @created_after");
|
||||
parameters.Add(("created_after", filter.CreatedAfter.Value));
|
||||
}
|
||||
|
||||
if (filter.ExpiringBefore.HasValue)
|
||||
{
|
||||
conditions.Add("expires_at < @expiring_before");
|
||||
parameters.Add(("expiring_before", filter.ExpiringBefore.Value));
|
||||
}
|
||||
|
||||
var whereClause = conditions.Count > 0
|
||||
? "WHERE " + string.Join(" AND ", conditions)
|
||||
: "";
|
||||
|
||||
return (whereClause, parameters);
|
||||
}
|
||||
|
||||
private static ExceptionObject MapException(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = reader.GetString(reader.GetOrdinal("exception_id")),
|
||||
Version = reader.GetInt32(reader.GetOrdinal("version")),
|
||||
Status = ParseStatus(reader.GetString(reader.GetOrdinal("status"))),
|
||||
Type = ParseType(reader.GetString(reader.GetOrdinal("type"))),
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
ArtifactDigest = GetNullableString(reader, reader.GetOrdinal("artifact_digest")),
|
||||
PurlPattern = GetNullableString(reader, reader.GetOrdinal("purl_pattern")),
|
||||
VulnerabilityId = GetNullableString(reader, reader.GetOrdinal("vulnerability_id")),
|
||||
PolicyRuleId = GetNullableString(reader, reader.GetOrdinal("policy_rule_id")),
|
||||
Environments = GetStringArray(reader, reader.GetOrdinal("environments")),
|
||||
TenantId = GetNullableGuid(reader, reader.GetOrdinal("tenant_id"))
|
||||
},
|
||||
OwnerId = reader.GetString(reader.GetOrdinal("owner_id")),
|
||||
RequesterId = reader.GetString(reader.GetOrdinal("requester_id")),
|
||||
ApproverIds = GetStringArray(reader, reader.GetOrdinal("approver_ids")),
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
|
||||
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
|
||||
ApprovedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("approved_at")),
|
||||
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at")),
|
||||
ReasonCode = ParseReason(reader.GetString(reader.GetOrdinal("reason_code"))),
|
||||
Rationale = reader.GetString(reader.GetOrdinal("rationale")),
|
||||
EvidenceRefs = ParseJsonArray(reader.GetString(reader.GetOrdinal("evidence_refs"))),
|
||||
CompensatingControls = ParseJsonArray(reader.GetString(reader.GetOrdinal("compensating_controls"))),
|
||||
Metadata = ParseJsonDictionary(reader.GetString(reader.GetOrdinal("metadata"))),
|
||||
TicketRef = GetNullableString(reader, reader.GetOrdinal("ticket_ref"))
|
||||
};
|
||||
}
|
||||
|
||||
private static ExceptionEvent MapEvent(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ExceptionEvent
|
||||
{
|
||||
EventId = reader.GetGuid(reader.GetOrdinal("id")),
|
||||
ExceptionId = reader.GetString(reader.GetOrdinal("exception_id")),
|
||||
SequenceNumber = reader.GetInt32(reader.GetOrdinal("sequence_number")),
|
||||
EventType = ParseEventType(reader.GetString(reader.GetOrdinal("event_type"))),
|
||||
ActorId = reader.GetString(reader.GetOrdinal("actor_id")),
|
||||
OccurredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("occurred_at")),
|
||||
PreviousStatus = ParseStatusNullable(GetNullableString(reader, reader.GetOrdinal("previous_status"))),
|
||||
NewStatus = ParseStatus(reader.GetString(reader.GetOrdinal("new_status"))),
|
||||
NewVersion = reader.GetInt32(reader.GetOrdinal("new_version")),
|
||||
Description = GetNullableString(reader, reader.GetOrdinal("description")),
|
||||
Details = ParseJsonDictionary(reader.GetString(reader.GetOrdinal("details"))),
|
||||
ClientInfo = GetNullableString(reader, reader.GetOrdinal("client_info"))
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> GetStringArray(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
return [];
|
||||
|
||||
var array = reader.GetFieldValue<string[]>(ordinal);
|
||||
return array.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ParseJsonArray(string json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json) || json == "[]")
|
||||
return [];
|
||||
|
||||
var array = JsonSerializer.Deserialize<string[]>(json);
|
||||
return array?.ToImmutableArray() ?? [];
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, string> ParseJsonDictionary(string json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json) || json == "{}")
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
||||
return dict?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
private static string GetScopeDescription(ExceptionScope scope)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(scope.ArtifactDigest))
|
||||
parts.Add($"artifact:{scope.ArtifactDigest[..Math.Min(16, scope.ArtifactDigest.Length)]}...");
|
||||
if (!string.IsNullOrEmpty(scope.VulnerabilityId))
|
||||
parts.Add($"vuln:{scope.VulnerabilityId}");
|
||||
if (!string.IsNullOrEmpty(scope.PurlPattern))
|
||||
parts.Add($"purl:{scope.PurlPattern}");
|
||||
if (!string.IsNullOrEmpty(scope.PolicyRuleId))
|
||||
parts.Add($"rule:{scope.PolicyRuleId}");
|
||||
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : "global";
|
||||
}
|
||||
|
||||
#region Enum Conversions
|
||||
|
||||
private static string StatusToString(ExceptionStatus status) => status switch
|
||||
{
|
||||
ExceptionStatus.Proposed => "proposed",
|
||||
ExceptionStatus.Approved => "approved",
|
||||
ExceptionStatus.Active => "active",
|
||||
ExceptionStatus.Expired => "expired",
|
||||
ExceptionStatus.Revoked => "revoked",
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static string? StatusToStringNullable(ExceptionStatus? status) =>
|
||||
status.HasValue ? StatusToString(status.Value) : null;
|
||||
|
||||
private static ExceptionStatus ParseStatus(string status) => status switch
|
||||
{
|
||||
"proposed" => ExceptionStatus.Proposed,
|
||||
"approved" => ExceptionStatus.Approved,
|
||||
"active" => ExceptionStatus.Active,
|
||||
"expired" => ExceptionStatus.Expired,
|
||||
"revoked" => ExceptionStatus.Revoked,
|
||||
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
|
||||
};
|
||||
|
||||
private static ExceptionStatus? ParseStatusNullable(string? status) =>
|
||||
status is null ? null : ParseStatus(status);
|
||||
|
||||
private static string TypeToString(ExceptionType type) => type switch
|
||||
{
|
||||
ExceptionType.Vulnerability => "vulnerability",
|
||||
ExceptionType.Policy => "policy",
|
||||
ExceptionType.Unknown => "unknown",
|
||||
ExceptionType.Component => "component",
|
||||
_ => throw new ArgumentException($"Unknown type: {type}", nameof(type))
|
||||
};
|
||||
|
||||
private static ExceptionType ParseType(string type) => type switch
|
||||
{
|
||||
"vulnerability" => ExceptionType.Vulnerability,
|
||||
"policy" => ExceptionType.Policy,
|
||||
"unknown" => ExceptionType.Unknown,
|
||||
"component" => ExceptionType.Component,
|
||||
_ => throw new ArgumentException($"Unknown type: {type}", nameof(type))
|
||||
};
|
||||
|
||||
private static string ReasonToString(ExceptionReason reason) => reason switch
|
||||
{
|
||||
ExceptionReason.FalsePositive => "false_positive",
|
||||
ExceptionReason.AcceptedRisk => "accepted_risk",
|
||||
ExceptionReason.CompensatingControl => "compensating_control",
|
||||
ExceptionReason.TestOnly => "test_only",
|
||||
ExceptionReason.VendorNotAffected => "vendor_not_affected",
|
||||
ExceptionReason.ScheduledFix => "scheduled_fix",
|
||||
ExceptionReason.DeprecationInProgress => "deprecation_in_progress",
|
||||
ExceptionReason.RuntimeMitigation => "runtime_mitigation",
|
||||
ExceptionReason.NetworkIsolation => "network_isolation",
|
||||
ExceptionReason.Other => "other",
|
||||
_ => throw new ArgumentException($"Unknown reason: {reason}", nameof(reason))
|
||||
};
|
||||
|
||||
private static ExceptionReason ParseReason(string reason) => reason switch
|
||||
{
|
||||
"false_positive" => ExceptionReason.FalsePositive,
|
||||
"accepted_risk" => ExceptionReason.AcceptedRisk,
|
||||
"compensating_control" => ExceptionReason.CompensatingControl,
|
||||
"test_only" => ExceptionReason.TestOnly,
|
||||
"vendor_not_affected" => ExceptionReason.VendorNotAffected,
|
||||
"scheduled_fix" => ExceptionReason.ScheduledFix,
|
||||
"deprecation_in_progress" => ExceptionReason.DeprecationInProgress,
|
||||
"runtime_mitigation" => ExceptionReason.RuntimeMitigation,
|
||||
"network_isolation" => ExceptionReason.NetworkIsolation,
|
||||
"other" => ExceptionReason.Other,
|
||||
_ => throw new ArgumentException($"Unknown reason: {reason}", nameof(reason))
|
||||
};
|
||||
|
||||
private static string EventTypeToString(ExceptionEventType eventType) => eventType switch
|
||||
{
|
||||
ExceptionEventType.Created => "created",
|
||||
ExceptionEventType.Updated => "updated",
|
||||
ExceptionEventType.Approved => "approved",
|
||||
ExceptionEventType.Activated => "activated",
|
||||
ExceptionEventType.Extended => "extended",
|
||||
ExceptionEventType.Revoked => "revoked",
|
||||
ExceptionEventType.Expired => "expired",
|
||||
ExceptionEventType.EvidenceAttached => "evidence_attached",
|
||||
ExceptionEventType.CompensatingControlAdded => "compensating_control_added",
|
||||
ExceptionEventType.Rejected => "rejected",
|
||||
_ => throw new ArgumentException($"Unknown event type: {eventType}", nameof(eventType))
|
||||
};
|
||||
|
||||
private static ExceptionEventType ParseEventType(string eventType) => eventType switch
|
||||
{
|
||||
"created" => ExceptionEventType.Created,
|
||||
"updated" => ExceptionEventType.Updated,
|
||||
"approved" => ExceptionEventType.Approved,
|
||||
"activated" => ExceptionEventType.Activated,
|
||||
"extended" => ExceptionEventType.Extended,
|
||||
"revoked" => ExceptionEventType.Revoked,
|
||||
"expired" => ExceptionEventType.Expired,
|
||||
"evidence_attached" => ExceptionEventType.EvidenceAttached,
|
||||
"compensating_control_added" => ExceptionEventType.CompensatingControlAdded,
|
||||
"rejected" => ExceptionEventType.Rejected,
|
||||
_ => throw new ArgumentException($"Unknown event type: {eventType}", nameof(eventType))
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
|
||||
|
||||
namespace StellaOps.Policy.Storage.Postgres;
|
||||
|
||||
@@ -34,6 +35,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
|
||||
services.AddScoped<IExceptionRepository, ExceptionRepository>();
|
||||
services.AddScoped<IAuditableExceptionRepository, PostgresExceptionObjectRepository>();
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
services.AddScoped<IExplanationRepository, ExplanationRepository>();
|
||||
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
|
||||
@@ -66,6 +68,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IRiskProfileRepository, RiskProfileRepository>();
|
||||
services.AddScoped<IEvaluationRunRepository, EvaluationRunRepository>();
|
||||
services.AddScoped<IExceptionRepository, ExceptionRepository>();
|
||||
services.AddScoped<IAuditableExceptionRepository, PostgresExceptionObjectRepository>();
|
||||
services.AddScoped<IReceiptRepository, PostgresReceiptRepository>();
|
||||
services.AddScoped<IExplanationRepository, ExplanationRepository>();
|
||||
services.AddScoped<IPolicyAuditRepository, PolicyAuditRepository>();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user