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:
StellaOps Bot
2025-12-21 00:34:35 +02:00
parent 6928124d33
commit b7b27c8740
32 changed files with 8687 additions and 64 deletions

View File

@@ -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
}

View File

@@ -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>();

View File

@@ -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>