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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,287 @@
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Policy.Engine.Domain;
// ============================================================================
// Exception API DTOs - Request Models
// ============================================================================
/// <summary>
/// Request to create a new exception.
/// </summary>
public sealed record CreateExceptionRequest
{
/// <summary>
/// Type of exception: vulnerability, policy, unknown, or component.
/// </summary>
[Required]
public required string Type { get; init; }
/// <summary>
/// Scope constraints for the exception.
/// </summary>
[Required]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Categorized reason for the exception.
/// </summary>
[Required]
public required string ReasonCode { get; init; }
/// <summary>
/// Detailed rationale explaining why this exception is necessary.
/// </summary>
[Required]
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
public required string Rationale { get; init; }
/// <summary>
/// When the exception should expire. Required and must be in the future.
/// </summary>
[Required]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Content-addressed references to supporting evidence.
/// </summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls in place that mitigate the risk.
/// </summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Additional metadata for organization-specific tracking.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Ticket or tracking system reference (e.g., JIRA-1234).
/// </summary>
[StringLength(100)]
public string? TicketRef { get; init; }
}
/// <summary>
/// Exception scope constraints.
/// </summary>
public sealed record ExceptionScopeDto
{
/// <summary>
/// Specific artifact digest (sha256:...) this exception applies to.
/// </summary>
public string? ArtifactDigest { get; init; }
/// <summary>
/// PURL pattern this exception applies to (supports wildcards).
/// </summary>
public string? PurlPattern { get; init; }
/// <summary>
/// Specific vulnerability ID (CVE-XXXX-XXXXX) this exception applies to.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// Policy rule identifier this exception bypasses.
/// </summary>
public string? PolicyRuleId { get; init; }
/// <summary>
/// Environments where this exception is valid. Empty means all environments.
/// </summary>
public IReadOnlyList<string>? Environments { get; init; }
}
/// <summary>
/// Request to update an existing exception.
/// </summary>
public sealed record UpdateExceptionRequest
{
/// <summary>
/// Version of the exception for optimistic concurrency.
/// </summary>
[Required]
public required int Version { get; init; }
/// <summary>
/// Updated rationale (if changing).
/// </summary>
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
public string? Rationale { get; init; }
/// <summary>
/// Updated evidence references.
/// </summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Updated compensating controls.
/// </summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Updated metadata.
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
/// <summary>
/// Updated ticket reference.
/// </summary>
[StringLength(100)]
public string? TicketRef { get; init; }
}
/// <summary>
/// Request to approve an exception.
/// </summary>
public sealed record ApproveExceptionRequest
{
/// <summary>
/// Optional comment from the approver.
/// </summary>
[StringLength(500)]
public string? Comment { get; init; }
}
/// <summary>
/// Request to extend an exception's expiry.
/// </summary>
public sealed record ExtendExceptionRequest
{
/// <summary>
/// Version of the exception for optimistic concurrency.
/// </summary>
[Required]
public required int Version { get; init; }
/// <summary>
/// New expiry date (must be in the future, max 1 year from now).
/// </summary>
[Required]
public required DateTimeOffset NewExpiresAt { get; init; }
/// <summary>
/// Reason for the extension.
/// </summary>
[StringLength(500)]
public string? Reason { get; init; }
}
/// <summary>
/// Request to revoke an exception.
/// </summary>
public sealed record RevokeExceptionRequest
{
/// <summary>
/// Reason for revoking the exception.
/// </summary>
[Required]
[StringLength(500)]
public required string Reason { get; init; }
}
// ============================================================================
// Exception API DTOs - Response Models
// ============================================================================
/// <summary>
/// Full exception response.
/// </summary>
public sealed record ExceptionDto
{
public required string ExceptionId { get; init; }
public required int Version { get; init; }
public required string Status { get; init; }
public required string Type { get; init; }
public required ExceptionScopeDto Scope { get; init; }
public required string OwnerId { get; init; }
public required string RequesterId { get; init; }
public IReadOnlyList<string> ApproverIds { get; init; } = [];
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset UpdatedAt { get; init; }
public DateTimeOffset? ApprovedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required string ReasonCode { get; init; }
public required string Rationale { get; init; }
public IReadOnlyList<string> EvidenceRefs { get; init; } = [];
public IReadOnlyList<string> CompensatingControls { get; init; } = [];
public IReadOnlyDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
public string? TicketRef { get; init; }
public bool IsEffective { get; init; }
public bool HasExpired { get; init; }
}
/// <summary>
/// Summary exception for list responses.
/// </summary>
public sealed record ExceptionSummaryDto
{
public required string ExceptionId { get; init; }
public required string Status { get; init; }
public required string Type { get; init; }
public string? VulnerabilityId { get; init; }
public string? PurlPattern { get; init; }
public required string OwnerId { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
public required string ReasonCode { get; init; }
public bool IsEffective { get; init; }
}
/// <summary>
/// Paginated list response.
/// </summary>
public sealed record ExceptionListResponse
{
public required IReadOnlyList<ExceptionSummaryDto> Items { get; init; }
public required int TotalCount { get; init; }
public required int Limit { get; init; }
public required int Offset { get; init; }
public bool HasMore => Offset + Items.Count < TotalCount;
}
/// <summary>
/// Exception event for audit history.
/// </summary>
public sealed record ExceptionEventDto
{
public required Guid EventId { get; init; }
public required int SequenceNumber { get; init; }
public required string EventType { get; init; }
public required string ActorId { get; init; }
public required DateTimeOffset OccurredAt { get; init; }
public string? PreviousStatus { get; init; }
public required string NewStatus { get; init; }
public int NewVersion { get; init; }
public string? Description { get; init; }
public IReadOnlyDictionary<string, string>? Details { get; init; }
}
/// <summary>
/// Exception history response.
/// </summary>
public sealed record ExceptionHistoryResponse
{
public required string ExceptionId { get; init; }
public required IReadOnlyList<ExceptionEventDto> Events { get; init; }
public required int EventCount { get; init; }
public DateTimeOffset? FirstEventAt { get; init; }
public DateTimeOffset? LastEventAt { get; init; }
}
/// <summary>
/// Exception counts by status.
/// </summary>
public sealed record ExceptionCountsDto
{
public int Total { get; init; }
public int Proposed { get; init; }
public int Approved { get; init; }
public int Active { get; init; }
public int Expired { get; init; }
public int Revoked { get; init; }
public int ExpiringSoon { get; init; }
}

View File

@@ -0,0 +1,260 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Engine.Domain;
/// <summary>
/// Maps between Exception domain models and API DTOs.
/// </summary>
public static class ExceptionMapper
{
/// <summary>
/// Maps an ExceptionObject to a full DTO.
/// </summary>
public static ExceptionDto ToDto(ExceptionObject exception)
{
return new ExceptionDto
{
ExceptionId = exception.ExceptionId,
Version = exception.Version,
Status = StatusToString(exception.Status),
Type = TypeToString(exception.Type),
Scope = ToScopeDto(exception.Scope),
OwnerId = exception.OwnerId,
RequesterId = exception.RequesterId,
ApproverIds = exception.ApproverIds.ToList(),
CreatedAt = exception.CreatedAt,
UpdatedAt = exception.UpdatedAt,
ApprovedAt = exception.ApprovedAt,
ExpiresAt = exception.ExpiresAt,
ReasonCode = ReasonToString(exception.ReasonCode),
Rationale = exception.Rationale,
EvidenceRefs = exception.EvidenceRefs.ToList(),
CompensatingControls = exception.CompensatingControls.ToList(),
Metadata = exception.Metadata,
TicketRef = exception.TicketRef,
IsEffective = exception.IsEffective,
HasExpired = exception.HasExpired
};
}
/// <summary>
/// Maps an ExceptionObject to a summary DTO for list responses.
/// </summary>
public static ExceptionSummaryDto ToSummaryDto(ExceptionObject exception)
{
return new ExceptionSummaryDto
{
ExceptionId = exception.ExceptionId,
Status = StatusToString(exception.Status),
Type = TypeToString(exception.Type),
VulnerabilityId = exception.Scope.VulnerabilityId,
PurlPattern = exception.Scope.PurlPattern,
OwnerId = exception.OwnerId,
ExpiresAt = exception.ExpiresAt,
ReasonCode = ReasonToString(exception.ReasonCode),
IsEffective = exception.IsEffective
};
}
/// <summary>
/// Maps an ExceptionScope to a DTO.
/// </summary>
public static ExceptionScopeDto ToScopeDto(ExceptionScope scope)
{
return new ExceptionScopeDto
{
ArtifactDigest = scope.ArtifactDigest,
PurlPattern = scope.PurlPattern,
VulnerabilityId = scope.VulnerabilityId,
PolicyRuleId = scope.PolicyRuleId,
Environments = scope.Environments.IsEmpty ? null : scope.Environments.ToList()
};
}
/// <summary>
/// Maps an ExceptionEvent to a DTO.
/// </summary>
public static ExceptionEventDto ToEventDto(ExceptionEvent evt)
{
return new ExceptionEventDto
{
EventId = evt.EventId,
SequenceNumber = evt.SequenceNumber,
EventType = EventTypeToString(evt.EventType),
ActorId = evt.ActorId,
OccurredAt = evt.OccurredAt,
PreviousStatus = evt.PreviousStatus.HasValue ? StatusToString(evt.PreviousStatus.Value) : null,
NewStatus = StatusToString(evt.NewStatus),
NewVersion = evt.NewVersion,
Description = evt.Description,
Details = evt.Details.IsEmpty ? null : evt.Details
};
}
/// <summary>
/// Maps an ExceptionHistory to a response DTO.
/// </summary>
public static ExceptionHistoryResponse ToHistoryResponse(ExceptionHistory history)
{
return new ExceptionHistoryResponse
{
ExceptionId = history.ExceptionId,
Events = history.Events.Select(ToEventDto).ToList(),
EventCount = history.EventCount,
FirstEventAt = history.FirstEventAt,
LastEventAt = history.LastEventAt
};
}
/// <summary>
/// Maps ExceptionCounts to a DTO.
/// </summary>
public static ExceptionCountsDto ToCountsDto(ExceptionCounts counts)
{
return new ExceptionCountsDto
{
Total = counts.Total,
Proposed = counts.Proposed,
Approved = counts.Approved,
Active = counts.Active,
Expired = counts.Expired,
Revoked = counts.Revoked,
ExpiringSoon = counts.ExpiringSoon
};
}
/// <summary>
/// Creates an ExceptionScope from a DTO.
/// </summary>
public static ExceptionScope FromScopeDto(ExceptionScopeDto dto, Guid? tenantId = null)
{
return new ExceptionScope
{
ArtifactDigest = dto.ArtifactDigest,
PurlPattern = dto.PurlPattern,
VulnerabilityId = dto.VulnerabilityId,
PolicyRuleId = dto.PolicyRuleId,
Environments = dto.Environments?.ToImmutableArray() ?? [],
TenantId = tenantId
};
}
/// <summary>
/// Creates an ExceptionObject from a create request.
/// </summary>
public static ExceptionObject FromCreateRequest(
CreateExceptionRequest request,
string exceptionId,
string ownerId,
string requesterId,
Guid? tenantId = null)
{
return new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ParseType(request.Type),
Scope = FromScopeDto(request.Scope, tenantId),
OwnerId = ownerId,
RequesterId = requesterId,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReason(request.ReasonCode),
Rationale = request.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? [],
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
TicketRef = request.TicketRef
};
}
#region String Conversions
public 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))
};
public static ExceptionStatus ParseStatus(string status) => status.ToLowerInvariant() switch
{
"proposed" => ExceptionStatus.Proposed,
"approved" => ExceptionStatus.Approved,
"active" => ExceptionStatus.Active,
"expired" => ExceptionStatus.Expired,
"revoked" => ExceptionStatus.Revoked,
_ => throw new ArgumentException($"Unknown status: {status}", nameof(status))
};
public 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))
};
public static ExceptionType ParseType(string type) => type.ToLowerInvariant() switch
{
"vulnerability" => ExceptionType.Vulnerability,
"policy" => ExceptionType.Policy,
"unknown" => ExceptionType.Unknown,
"component" => ExceptionType.Component,
_ => throw new ArgumentException($"Unknown type: {type}", nameof(type))
};
public 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))
};
public static ExceptionReason ParseReason(string reason) => reason.ToLowerInvariant() 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))
};
public 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))
};
#endregion
}

View File

@@ -25,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />

View File

@@ -0,0 +1,466 @@
// <copyright file="ExceptionContracts.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Gateway.Contracts;
/// <summary>
/// Request to create a new exception.
/// </summary>
public sealed record CreateExceptionRequest
{
/// <summary>
/// Type of exception (vulnerability, policy, unknown, component).
/// </summary>
[Required]
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Exception scope defining what this exception applies to.
/// </summary>
[Required]
[JsonPropertyName("scope")]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Owner ID (user or team accountable).
/// </summary>
[Required]
[JsonPropertyName("ownerId")]
public required string OwnerId { get; init; }
/// <summary>
/// Reason code for the exception.
/// </summary>
[Required]
[JsonPropertyName("reasonCode")]
public required string ReasonCode { get; init; }
/// <summary>
/// Detailed rationale explaining why this exception is necessary.
/// </summary>
[Required]
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// When the exception should expire.
/// </summary>
[Required]
[JsonPropertyName("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Content-addressed evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls in place.
/// </summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// External ticket reference (e.g., JIRA-1234).
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Exception scope DTO.
/// </summary>
public sealed record ExceptionScopeDto
{
/// <summary>
/// Specific artifact digest (sha256:...).
/// </summary>
[JsonPropertyName("artifactDigest")]
public string? ArtifactDigest { get; init; }
/// <summary>
/// PURL pattern (supports wildcards: pkg:npm/lodash@*).
/// </summary>
[JsonPropertyName("purlPattern")]
public string? PurlPattern { get; init; }
/// <summary>
/// Specific vulnerability ID (CVE-XXXX-XXXXX).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public string? VulnerabilityId { get; init; }
/// <summary>
/// Policy rule identifier to bypass.
/// </summary>
[JsonPropertyName("policyRuleId")]
public string? PolicyRuleId { get; init; }
/// <summary>
/// Environments where exception is valid (empty = all).
/// </summary>
[JsonPropertyName("environments")]
public IReadOnlyList<string>? Environments { get; init; }
}
/// <summary>
/// Request to update an exception.
/// </summary>
public sealed record UpdateExceptionRequest
{
/// <summary>
/// Updated rationale.
/// </summary>
[MinLength(50, ErrorMessage = "Rationale must be at least 50 characters.")]
[JsonPropertyName("rationale")]
public string? Rationale { get; init; }
/// <summary>
/// Updated evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>
/// Updated compensating controls.
/// </summary>
[JsonPropertyName("compensatingControls")]
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>
/// Updated ticket reference.
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Updated metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Request to approve an exception.
/// </summary>
public sealed record ApproveExceptionRequest
{
/// <summary>
/// Optional comment from approver.
/// </summary>
[JsonPropertyName("comment")]
public string? Comment { get; init; }
}
/// <summary>
/// Request to extend an exception's expiry.
/// </summary>
public sealed record ExtendExceptionRequest
{
/// <summary>
/// New expiry date.
/// </summary>
[Required]
[JsonPropertyName("newExpiresAt")]
public required DateTimeOffset NewExpiresAt { get; init; }
/// <summary>
/// Reason for extension.
/// </summary>
[Required]
[MinLength(20, ErrorMessage = "Extension reason must be at least 20 characters.")]
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}
/// <summary>
/// Request to revoke an exception.
/// </summary>
public sealed record RevokeExceptionRequest
{
/// <summary>
/// Reason for revocation.
/// </summary>
[Required]
[MinLength(10, ErrorMessage = "Revocation reason must be at least 10 characters.")]
[JsonPropertyName("reason")]
public required string Reason { get; init; }
}
/// <summary>
/// Exception response DTO.
/// </summary>
public sealed record ExceptionResponse
{
/// <summary>
/// Unique exception ID.
/// </summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>
/// Version for optimistic concurrency.
/// </summary>
[JsonPropertyName("version")]
public required int Version { get; init; }
/// <summary>
/// Current status.
/// </summary>
[JsonPropertyName("status")]
public required string Status { get; init; }
/// <summary>
/// Exception type.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Exception scope.
/// </summary>
[JsonPropertyName("scope")]
public required ExceptionScopeDto Scope { get; init; }
/// <summary>
/// Owner ID.
/// </summary>
[JsonPropertyName("ownerId")]
public required string OwnerId { get; init; }
/// <summary>
/// Requester ID.
/// </summary>
[JsonPropertyName("requesterId")]
public required string RequesterId { get; init; }
/// <summary>
/// Approver IDs.
/// </summary>
[JsonPropertyName("approverIds")]
public required IReadOnlyList<string> ApproverIds { get; init; }
/// <summary>
/// Created timestamp.
/// </summary>
[JsonPropertyName("createdAt")]
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last updated timestamp.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Approved timestamp.
/// </summary>
[JsonPropertyName("approvedAt")]
public DateTimeOffset? ApprovedAt { get; init; }
/// <summary>
/// Expiry timestamp.
/// </summary>
[JsonPropertyName("expiresAt")]
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Reason code.
/// </summary>
[JsonPropertyName("reasonCode")]
public required string ReasonCode { get; init; }
/// <summary>
/// Rationale.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// Evidence references.
/// </summary>
[JsonPropertyName("evidenceRefs")]
public required IReadOnlyList<string> EvidenceRefs { get; init; }
/// <summary>
/// Compensating controls.
/// </summary>
[JsonPropertyName("compensatingControls")]
public required IReadOnlyList<string> CompensatingControls { get; init; }
/// <summary>
/// Ticket reference.
/// </summary>
[JsonPropertyName("ticketRef")]
public string? TicketRef { get; init; }
/// <summary>
/// Metadata.
/// </summary>
[JsonPropertyName("metadata")]
public required IReadOnlyDictionary<string, string> Metadata { get; init; }
}
/// <summary>
/// Paginated list of exceptions.
/// </summary>
public sealed record ExceptionListResponse
{
/// <summary>
/// List of exceptions.
/// </summary>
[JsonPropertyName("items")]
public required IReadOnlyList<ExceptionResponse> Items { get; init; }
/// <summary>
/// Total count.
/// </summary>
[JsonPropertyName("totalCount")]
public required int TotalCount { get; init; }
/// <summary>
/// Offset.
/// </summary>
[JsonPropertyName("offset")]
public required int Offset { get; init; }
/// <summary>
/// Limit.
/// </summary>
[JsonPropertyName("limit")]
public required int Limit { get; init; }
}
/// <summary>
/// Exception event DTO.
/// </summary>
public sealed record ExceptionEventDto
{
/// <summary>
/// Event ID.
/// </summary>
[JsonPropertyName("eventId")]
public required Guid EventId { get; init; }
/// <summary>
/// Sequence number.
/// </summary>
[JsonPropertyName("sequenceNumber")]
public required int SequenceNumber { get; init; }
/// <summary>
/// Event type.
/// </summary>
[JsonPropertyName("eventType")]
public required string EventType { get; init; }
/// <summary>
/// Actor ID.
/// </summary>
[JsonPropertyName("actorId")]
public required string ActorId { get; init; }
/// <summary>
/// Occurred timestamp.
/// </summary>
[JsonPropertyName("occurredAt")]
public required DateTimeOffset OccurredAt { get; init; }
/// <summary>
/// Previous status.
/// </summary>
[JsonPropertyName("previousStatus")]
public string? PreviousStatus { get; init; }
/// <summary>
/// New status.
/// </summary>
[JsonPropertyName("newStatus")]
public required string NewStatus { get; init; }
/// <summary>
/// Description.
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
}
/// <summary>
/// Exception history response.
/// </summary>
public sealed record ExceptionHistoryResponse
{
/// <summary>
/// Exception ID.
/// </summary>
[JsonPropertyName("exceptionId")]
public required string ExceptionId { get; init; }
/// <summary>
/// Events in chronological order.
/// </summary>
[JsonPropertyName("events")]
public required IReadOnlyList<ExceptionEventDto> Events { get; init; }
}
/// <summary>
/// Exception counts summary.
/// </summary>
public sealed record ExceptionCountsResponse
{
/// <summary>
/// Total count.
/// </summary>
[JsonPropertyName("total")]
public required int Total { get; init; }
/// <summary>
/// Proposed count.
/// </summary>
[JsonPropertyName("proposed")]
public required int Proposed { get; init; }
/// <summary>
/// Approved count.
/// </summary>
[JsonPropertyName("approved")]
public required int Approved { get; init; }
/// <summary>
/// Active count.
/// </summary>
[JsonPropertyName("active")]
public required int Active { get; init; }
/// <summary>
/// Expired count.
/// </summary>
[JsonPropertyName("expired")]
public required int Expired { get; init; }
/// <summary>
/// Revoked count.
/// </summary>
[JsonPropertyName("revoked")]
public required int Revoked { get; init; }
/// <summary>
/// Count expiring within 7 days.
/// </summary>
[JsonPropertyName("expiringSoon")]
public required int ExpiringSoon { get; init; }
}

View File

@@ -0,0 +1,553 @@
// <copyright file="ExceptionEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Gateway.Contracts;
namespace StellaOps.Policy.Gateway.Endpoints;
/// <summary>
/// Exception API endpoints for Policy Gateway.
/// </summary>
public static class ExceptionEndpoints
{
/// <summary>
/// Maps exception endpoints to the application.
/// </summary>
public static void MapExceptionEndpoints(this WebApplication app)
{
var exceptions = app.MapGroup("/api/policy/exceptions")
.WithTags("Exceptions");
// GET /api/policy/exceptions - List exceptions with filters
exceptions.MapGet(string.Empty, async Task<IResult>(
[FromQuery] string? status,
[FromQuery] string? type,
[FromQuery] string? vulnerabilityId,
[FromQuery] string? purlPattern,
[FromQuery] string? environment,
[FromQuery] string? ownerId,
[FromQuery] int? limit,
[FromQuery] int? offset,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var filter = new ExceptionFilter
{
Status = ParseStatus(status),
Type = ParseType(type),
VulnerabilityId = vulnerabilityId,
PurlPattern = purlPattern,
Environment = environment,
OwnerId = ownerId,
Limit = Math.Clamp(limit ?? 50, 1, 100),
Offset = offset ?? 0
};
var results = await repository.GetByFilterAsync(filter, cancellationToken);
var counts = await repository.GetCountsAsync(null, cancellationToken);
return Results.Ok(new ExceptionListResponse
{
Items = results.Select(ToDto).ToList(),
TotalCount = counts.Total,
Offset = filter.Offset,
Limit = filter.Limit
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// GET /api/policy/exceptions/counts - Get exception counts
exceptions.MapGet("/counts", async Task<IResult>(
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var counts = await repository.GetCountsAsync(null, cancellationToken);
return Results.Ok(new ExceptionCountsResponse
{
Total = counts.Total,
Proposed = counts.Proposed,
Approved = counts.Approved,
Active = counts.Active,
Expired = counts.Expired,
Revoked = counts.Revoked,
ExpiringSoon = counts.ExpiringSoon
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// GET /api/policy/exceptions/{id} - Get exception by ID
exceptions.MapGet("/{id}", async Task<IResult>(
string id,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var exception = await repository.GetByIdAsync(id, cancellationToken);
if (exception is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Exception not found",
Status = 404,
Detail = $"No exception found with ID: {id}"
});
}
return Results.Ok(ToDto(exception));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// GET /api/policy/exceptions/{id}/history - Get exception history
exceptions.MapGet("/{id}/history", async Task<IResult>(
string id,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var history = await repository.GetHistoryAsync(id, cancellationToken);
return Results.Ok(new ExceptionHistoryResponse
{
ExceptionId = history.ExceptionId,
Events = history.Events.Select(e => new ExceptionEventDto
{
EventId = e.EventId,
SequenceNumber = e.SequenceNumber,
EventType = e.EventType.ToString().ToLowerInvariant(),
ActorId = e.ActorId,
OccurredAt = e.OccurredAt,
PreviousStatus = e.PreviousStatus?.ToString().ToLowerInvariant(),
NewStatus = e.NewStatus.ToString().ToLowerInvariant(),
Description = e.Description
}).ToList()
});
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
// POST /api/policy/exceptions - Create exception
exceptions.MapPost(string.Empty, async Task<IResult>(
CreateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Request body required",
Status = 400
});
}
// Validate expiry is in future
if (request.ExpiresAt <= DateTimeOffset.UtcNow)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid expiry",
Status = 400,
Detail = "Expiry date must be in the future"
});
}
// Validate expiry is not more than 1 year
if (request.ExpiresAt > DateTimeOffset.UtcNow.AddYears(1))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid expiry",
Status = 400,
Detail = "Expiry date cannot be more than 1 year in the future"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var exceptionId = $"EXC-{Guid.NewGuid():N}"[..20];
var exception = new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ParseTypeRequired(request.Type),
Scope = new ExceptionScope
{
ArtifactDigest = request.Scope.ArtifactDigest,
PurlPattern = request.Scope.PurlPattern,
VulnerabilityId = request.Scope.VulnerabilityId,
PolicyRuleId = request.Scope.PolicyRuleId,
Environments = request.Scope.Environments?.ToImmutableArray() ?? []
},
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReasonRequired(request.ReasonCode),
Rationale = request.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? [],
TicketRef = request.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
};
var created = await repository.CreateAsync(exception, actorId, clientInfo, cancellationToken);
return Results.Created($"/api/policy/exceptions/{created.ExceptionId}", ToDto(created));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
// PUT /api/policy/exceptions/{id} - Update exception
exceptions.MapPut("/{id}", async Task<IResult>(
string id,
UpdateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Exception not found",
Status = 404
});
}
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Cannot update",
Status = 400,
Detail = "Cannot update an expired or revoked exception"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow,
Rationale = request.Rationale ?? existing.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? existing.EvidenceRefs,
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? existing.CompensatingControls,
TicketRef = request.TicketRef ?? existing.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? existing.Metadata
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Updated, actorId, "Exception updated", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAuthor));
// POST /api/policy/exceptions/{id}/approve - Approve exception
exceptions.MapPost("/{id}/approve", async Task<IResult>(
string id,
ApproveExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = "Exception not found", Status = 404 });
}
if (existing.Status != ExceptionStatus.Proposed)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state transition",
Status = 400,
Detail = "Only proposed exceptions can be approved"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
// Approver cannot be requester
if (actorId == existing.RequesterId)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Self-approval not allowed",
Status = 400,
Detail = "Requester cannot approve their own exception"
});
}
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow,
ApprovedAt = DateTimeOffset.UtcNow,
ApproverIds = existing.ApproverIds.Add(actorId)
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Approved, actorId, request?.Comment ?? "Exception approved", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
// POST /api/policy/exceptions/{id}/activate - Activate approved exception
exceptions.MapPost("/{id}/activate", async Task<IResult>(
string id,
HttpContext context,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = "Exception not found", Status = 404 });
}
if (existing.Status != ExceptionStatus.Approved)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state transition",
Status = 400,
Detail = "Only approved exceptions can be activated"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Active,
UpdatedAt = DateTimeOffset.UtcNow
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Activated, actorId, "Exception activated", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
// POST /api/policy/exceptions/{id}/extend - Extend expiry
exceptions.MapPost("/{id}/extend", async Task<IResult>(
string id,
ExtendExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = "Exception not found", Status = 404 });
}
if (existing.Status != ExceptionStatus.Active)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state",
Status = 400,
Detail = "Only active exceptions can be extended"
});
}
if (request.NewExpiresAt <= existing.ExpiresAt)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid expiry",
Status = 400,
Detail = "New expiry must be after current expiry"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = request.NewExpiresAt
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Extended, actorId, request.Reason, clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
// DELETE /api/policy/exceptions/{id} - Revoke exception
exceptions.MapDelete("/{id}", async Task<IResult>(
string id,
[FromBody] RevokeExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
if (existing is null)
{
return Results.NotFound(new ProblemDetails { Title = "Exception not found", Status = 404 });
}
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid state",
Status = 400,
Detail = "Exception is already expired or revoked"
});
}
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Revoked,
UpdatedAt = DateTimeOffset.UtcNow
};
var result = await repository.UpdateAsync(
updated, ExceptionEventType.Revoked, actorId, request?.Reason ?? "Exception revoked", clientInfo, cancellationToken);
return Results.Ok(ToDto(result));
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyOperate));
// GET /api/policy/exceptions/expiring - Get exceptions expiring soon
exceptions.MapGet("/expiring", async Task<IResult>(
[FromQuery] int? days,
IExceptionRepository repository,
CancellationToken cancellationToken) =>
{
var horizon = TimeSpan.FromDays(days ?? 7);
var results = await repository.GetExpiringAsync(horizon, cancellationToken);
return Results.Ok(results.Select(ToDto).ToList());
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyRead));
}
#region Helpers
private static string GetActorId(HttpContext context)
{
return context.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? context.User.FindFirstValue("sub")
?? "anonymous";
}
private static string? GetClientInfo(HttpContext context)
{
var ip = context.Connection.RemoteIpAddress?.ToString();
var userAgent = context.Request.Headers.UserAgent.FirstOrDefault();
return string.IsNullOrEmpty(ip) ? null : $"{ip}; {userAgent}";
}
private static ExceptionResponse ToDto(ExceptionObject ex) => new()
{
ExceptionId = ex.ExceptionId,
Version = ex.Version,
Status = ex.Status.ToString().ToLowerInvariant(),
Type = ex.Type.ToString().ToLowerInvariant(),
Scope = new ExceptionScopeDto
{
ArtifactDigest = ex.Scope.ArtifactDigest,
PurlPattern = ex.Scope.PurlPattern,
VulnerabilityId = ex.Scope.VulnerabilityId,
PolicyRuleId = ex.Scope.PolicyRuleId,
Environments = ex.Scope.Environments.ToList()
},
OwnerId = ex.OwnerId,
RequesterId = ex.RequesterId,
ApproverIds = ex.ApproverIds.ToList(),
CreatedAt = ex.CreatedAt,
UpdatedAt = ex.UpdatedAt,
ApprovedAt = ex.ApprovedAt,
ExpiresAt = ex.ExpiresAt,
ReasonCode = ex.ReasonCode.ToString().ToLowerInvariant(),
Rationale = ex.Rationale,
EvidenceRefs = ex.EvidenceRefs.ToList(),
CompensatingControls = ex.CompensatingControls.ToList(),
TicketRef = ex.TicketRef,
Metadata = ex.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
private static ExceptionStatus? ParseStatus(string? status)
{
if (string.IsNullOrEmpty(status)) return null;
return status.ToLowerInvariant() switch
{
"proposed" => ExceptionStatus.Proposed,
"approved" => ExceptionStatus.Approved,
"active" => ExceptionStatus.Active,
"expired" => ExceptionStatus.Expired,
"revoked" => ExceptionStatus.Revoked,
_ => null
};
}
private static ExceptionType? ParseType(string? type)
{
if (string.IsNullOrEmpty(type)) return null;
return type.ToLowerInvariant() switch
{
"vulnerability" => ExceptionType.Vulnerability,
"policy" => ExceptionType.Policy,
"unknown" => ExceptionType.Unknown,
"component" => ExceptionType.Component,
_ => null
};
}
private static ExceptionType ParseTypeRequired(string type)
{
return type.ToLowerInvariant() switch
{
"vulnerability" => ExceptionType.Vulnerability,
"policy" => ExceptionType.Policy,
"unknown" => ExceptionType.Unknown,
"component" => ExceptionType.Component,
_ => throw new ArgumentException($"Invalid exception type: {type}")
};
}
private static ExceptionReason ParseReasonRequired(string reason)
{
return reason.ToLowerInvariant() switch
{
"false_positive" or "falsepositive" => ExceptionReason.FalsePositive,
"accepted_risk" or "acceptedrisk" => ExceptionReason.AcceptedRisk,
"compensating_control" or "compensatingcontrol" => ExceptionReason.CompensatingControl,
"test_only" or "testonly" => ExceptionReason.TestOnly,
"vendor_not_affected" or "vendornotaffected" => ExceptionReason.VendorNotAffected,
"scheduled_fix" or "scheduledfix" => ExceptionReason.ScheduledFix,
"deprecation_in_progress" or "deprecationinprogress" => ExceptionReason.DeprecationInProgress,
"runtime_mitigation" or "runtimemitigation" => ExceptionReason.RuntimeMitigation,
"network_isolation" or "networkisolation" => ExceptionReason.NetworkIsolation,
"other" => ExceptionReason.Other,
_ => throw new ArgumentException($"Invalid reason code: {reason}")
};
}
#endregion
}

View File

@@ -15,9 +15,11 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Configuration;
using StellaOps.Policy.Gateway.Clients;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.Policy.Gateway.Endpoints;
using StellaOps.Policy.Gateway.Infrastructure;
using StellaOps.Policy.Gateway.Options;
using StellaOps.Policy.Gateway.Services;
using StellaOps.Policy.Storage.Postgres;
using Polly;
using Polly.Extensions.Http;
using StellaOps.AirGap.Policy;
@@ -103,6 +105,20 @@ builder.Services.AddHealthChecks();
builder.Services.AddAuthentication();
builder.Services.AddAuthorization();
builder.Services.AddStellaOpsScopeHandler();
builder.Services.AddPolicyPostgresStorage(builder.Configuration);
builder.Services.AddMemoryCache();
// Exception services
builder.Services.Configure<ApprovalWorkflowOptions>(
builder.Configuration.GetSection(ApprovalWorkflowOptions.SectionName));
builder.Services.Configure<ExceptionExpiryOptions>(
builder.Configuration.GetSection(ExceptionExpiryOptions.SectionName));
builder.Services.AddScoped<IExceptionService, ExceptionService>();
builder.Services.AddScoped<IExceptionQueryService, ExceptionQueryService>();
builder.Services.AddScoped<IApprovalWorkflowService, ApprovalWorkflowService>();
builder.Services.AddSingleton<IExceptionNotificationService, NoOpExceptionNotificationService>();
builder.Services.AddHostedService<ExceptionExpiryWorker>();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configurationSection: $"{PolicyGatewayOptions.SectionName}:ResourceServer");
@@ -467,6 +483,9 @@ cvss.MapGet("/policies", async Task<IResult>(
})
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.FindingsRead));
// Exception management endpoints
app.MapExceptionEndpoints();
app.Run();
static IAsyncPolicy<HttpResponseMessage> CreateAuthorityRetryPolicy(IServiceProvider provider)

View File

@@ -0,0 +1,275 @@
// <copyright file="ApprovalWorkflowService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Gateway.Services;
/// <summary>
/// Approval policy configuration per environment.
/// </summary>
public sealed record ApprovalPolicy
{
/// <summary>Environment name (dev, staging, prod).</summary>
public required string Environment { get; init; }
/// <summary>Number of required approvers.</summary>
public required int RequiredApprovers { get; init; }
/// <summary>Whether requester can approve their own exception.</summary>
public required bool RequesterCanApprove { get; init; }
/// <summary>Deadline for approval before auto-reject.</summary>
public required TimeSpan ApprovalDeadline { get; init; }
/// <summary>Roles allowed to approve.</summary>
public ImmutableArray<string> AllowedApproverRoles { get; init; } = [];
/// <summary>Whether to auto-approve in this environment.</summary>
public bool AutoApprove { get; init; }
}
/// <summary>
/// Options for approval workflow configuration.
/// </summary>
public sealed class ApprovalWorkflowOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Policy:Exceptions:Approval";
/// <summary>Default policy for environments not explicitly configured.</summary>
public ApprovalPolicy DefaultPolicy { get; set; } = new()
{
Environment = "default",
RequiredApprovers = 1,
RequesterCanApprove = false,
ApprovalDeadline = TimeSpan.FromDays(7),
AutoApprove = false
};
/// <summary>Environment-specific policies.</summary>
public Dictionary<string, ApprovalPolicy> EnvironmentPolicies { get; set; } = new()
{
["dev"] = new ApprovalPolicy
{
Environment = "dev",
RequiredApprovers = 0,
RequesterCanApprove = true,
ApprovalDeadline = TimeSpan.FromDays(30),
AutoApprove = true
},
["staging"] = new ApprovalPolicy
{
Environment = "staging",
RequiredApprovers = 1,
RequesterCanApprove = false,
ApprovalDeadline = TimeSpan.FromDays(14)
},
["prod"] = new ApprovalPolicy
{
Environment = "prod",
RequiredApprovers = 2,
RequesterCanApprove = false,
ApprovalDeadline = TimeSpan.FromDays(7),
AllowedApproverRoles = ["security-lead", "security-admin"]
}
};
}
/// <summary>
/// Result of approval validation.
/// </summary>
public sealed record ApprovalValidationResult
{
/// <summary>Whether approval is valid.</summary>
public bool IsValid { get; init; }
/// <summary>Error message if invalid.</summary>
public string? Error { get; init; }
/// <summary>Whether this approval completes the workflow.</summary>
public bool IsComplete { get; init; }
/// <summary>Number of additional approvals needed.</summary>
public int ApprovalsRemaining { get; init; }
/// <summary>Creates a valid result.</summary>
public static ApprovalValidationResult Valid(bool isComplete, int remaining = 0) => new()
{
IsValid = true,
IsComplete = isComplete,
ApprovalsRemaining = remaining
};
/// <summary>Creates an invalid result.</summary>
public static ApprovalValidationResult Invalid(string error) => new()
{
IsValid = false,
Error = error
};
}
/// <summary>
/// Service for managing exception approval workflow.
/// </summary>
public interface IApprovalWorkflowService
{
/// <summary>
/// Gets the approval policy for an environment.
/// </summary>
ApprovalPolicy GetPolicyForEnvironment(string environment);
/// <summary>
/// Validates whether an approval is allowed.
/// </summary>
/// <param name="exception">The exception being approved.</param>
/// <param name="approverId">The ID of the approver.</param>
/// <param name="approverRoles">Roles of the approver.</param>
/// <returns>Validation result.</returns>
ApprovalValidationResult ValidateApproval(
ExceptionObject exception,
string approverId,
IReadOnlyList<string>? approverRoles = null);
/// <summary>
/// Checks if an exception should be auto-approved.
/// </summary>
bool ShouldAutoApprove(ExceptionObject exception);
/// <summary>
/// Checks if an exception approval has expired (deadline passed).
/// </summary>
bool IsApprovalExpired(ExceptionObject exception);
/// <summary>
/// Gets the deadline for exception approval.
/// </summary>
DateTimeOffset GetApprovalDeadline(ExceptionObject exception);
}
/// <summary>
/// Implementation of approval workflow service.
/// </summary>
public sealed class ApprovalWorkflowService : IApprovalWorkflowService
{
private readonly ApprovalWorkflowOptions _options;
private readonly TimeProvider _timeProvider;
private readonly IExceptionNotificationService _notificationService;
private readonly ILogger<ApprovalWorkflowService> _logger;
/// <summary>
/// Creates a new approval workflow service.
/// </summary>
public ApprovalWorkflowService(
IOptions<ApprovalWorkflowOptions> options,
TimeProvider timeProvider,
IExceptionNotificationService notificationService,
ILogger<ApprovalWorkflowService> logger)
{
_options = options.Value;
_timeProvider = timeProvider;
_notificationService = notificationService;
_logger = logger;
}
/// <inheritdoc />
public ApprovalPolicy GetPolicyForEnvironment(string environment)
{
if (_options.EnvironmentPolicies.TryGetValue(environment.ToLowerInvariant(), out var policy))
{
return policy;
}
return _options.DefaultPolicy;
}
/// <inheritdoc />
public ApprovalValidationResult ValidateApproval(
ExceptionObject exception,
string approverId,
IReadOnlyList<string>? approverRoles = null)
{
// Determine environment from scope
var environment = exception.Scope.Environments.Length > 0
? exception.Scope.Environments[0]
: "default";
var policy = GetPolicyForEnvironment(environment);
// Check if self-approval is allowed
if (approverId == exception.RequesterId && !policy.RequesterCanApprove)
{
return ApprovalValidationResult.Invalid("Requester cannot approve their own exception in this environment.");
}
// Check if approver already approved
if (exception.ApproverIds.Contains(approverId))
{
return ApprovalValidationResult.Invalid("You have already approved this exception.");
}
// Check role requirements
if (policy.AllowedApproverRoles.Length > 0)
{
var hasRequiredRole = approverRoles?.Any(r =>
policy.AllowedApproverRoles.Contains(r, StringComparer.OrdinalIgnoreCase)) ?? false;
if (!hasRequiredRole)
{
return ApprovalValidationResult.Invalid(
$"Approval requires one of these roles: {string.Join(", ", policy.AllowedApproverRoles)}");
}
}
// Check approval deadline
if (IsApprovalExpired(exception))
{
return ApprovalValidationResult.Invalid("Approval deadline has passed. Exception must be re-submitted.");
}
// Calculate remaining approvals needed
var currentApprovals = exception.ApproverIds.Length + 1; // +1 for this approval
var remaining = Math.Max(0, policy.RequiredApprovers - currentApprovals);
var isComplete = remaining == 0;
_logger.LogDebug(
"Approval validated for {ExceptionId}: current={Current}, required={Required}, complete={Complete}",
exception.ExceptionId, currentApprovals, policy.RequiredApprovers, isComplete);
return ApprovalValidationResult.Valid(isComplete, remaining);
}
/// <inheritdoc />
public bool ShouldAutoApprove(ExceptionObject exception)
{
var environment = exception.Scope.Environments.Length > 0
? exception.Scope.Environments[0]
: "default";
var policy = GetPolicyForEnvironment(environment);
return policy.AutoApprove;
}
/// <inheritdoc />
public bool IsApprovalExpired(ExceptionObject exception)
{
var deadline = GetApprovalDeadline(exception);
return _timeProvider.GetUtcNow() > deadline;
}
/// <inheritdoc />
public DateTimeOffset GetApprovalDeadline(ExceptionObject exception)
{
var environment = exception.Scope.Environments.Length > 0
? exception.Scope.Environments[0]
: "default";
var policy = GetPolicyForEnvironment(environment);
return exception.CreatedAt.Add(policy.ApprovalDeadline);
}
}

View File

@@ -0,0 +1,235 @@
// <copyright file="ExceptionExpiryWorker.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Gateway.Services;
/// <summary>
/// Options for exception expiry worker.
/// </summary>
public sealed class ExceptionExpiryOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Policy:Exceptions:Expiry";
/// <summary>Whether the worker is enabled.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Interval between expiry checks.</summary>
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
/// <summary>Warning horizon for expiry notifications.</summary>
public TimeSpan WarningHorizon { get; set; } = TimeSpan.FromDays(7);
/// <summary>Initial delay before first run.</summary>
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>
/// Background worker that marks expired exceptions and sends expiry warnings.
/// Runs hourly by default.
/// </summary>
public sealed class ExceptionExpiryWorker : BackgroundService
{
private const string SystemActorId = "system:expiry-worker";
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<ExceptionExpiryOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionExpiryWorker> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Policy.ExceptionExpiry");
/// <summary>
/// Creates a new exception expiry worker.
/// </summary>
public ExceptionExpiryWorker(
IServiceScopeFactory scopeFactory,
IOptions<ExceptionExpiryOptions> options,
TimeProvider timeProvider,
ILogger<ExceptionExpiryWorker> logger)
{
_scopeFactory = scopeFactory;
_options = options;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Exception expiry worker started");
// Initial delay to let the system stabilize
await Task.Delay(_options.Value.InitialDelay, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogDebug("Exception expiry worker is disabled");
await Task.Delay(opts.Interval, stoppingToken);
continue;
}
using var activity = _activitySource.StartActivity("exception.expiry.check", ActivityKind.Internal);
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
await RunExpiryCycleAsync(scope.ServiceProvider, opts, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception expiry cycle failed");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
}
await Task.Delay(opts.Interval, stoppingToken);
}
_logger.LogInformation("Exception expiry worker stopped");
}
private async Task RunExpiryCycleAsync(
IServiceProvider services,
ExceptionExpiryOptions options,
CancellationToken cancellationToken)
{
var repository = services.GetRequiredService<IExceptionRepository>();
var notificationService = services.GetRequiredService<IExceptionNotificationService>();
// Process expired exceptions
var expiredCount = await ProcessExpiredExceptionsAsync(repository, cancellationToken);
// Send warnings for exceptions expiring soon
var warnedCount = await ProcessExpiringWarningsAsync(
repository, notificationService, options.WarningHorizon, cancellationToken);
if (expiredCount > 0 || warnedCount > 0)
{
_logger.LogInformation(
"Exception expiry cycle complete: {ExpiredCount} expired, {WarnedCount} warnings sent",
expiredCount, warnedCount);
}
}
private async Task<int> ProcessExpiredExceptionsAsync(
IExceptionRepository repository,
CancellationToken cancellationToken)
{
var expired = await repository.GetExpiredActiveAsync(cancellationToken);
if (expired.Count == 0)
{
return 0;
}
_logger.LogDebug("Found {Count} expired active exceptions to process", expired.Count);
var processedCount = 0;
foreach (var exception in expired)
{
try
{
var updated = exception with
{
Version = exception.Version + 1,
Status = ExceptionStatus.Expired,
UpdatedAt = _timeProvider.GetUtcNow()
};
await repository.UpdateAsync(
updated,
ExceptionEventType.Expired,
SystemActorId,
"Exception expired automatically",
"system:expiry-worker",
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} marked as expired",
exception.ExceptionId);
processedCount++;
}
catch (ConcurrencyException)
{
_logger.LogWarning(
"Concurrency conflict expiring exception {ExceptionId}, will retry next cycle",
exception.ExceptionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to expire exception {ExceptionId}", exception.ExceptionId);
}
}
return processedCount;
}
private async Task<int> ProcessExpiringWarningsAsync(
IExceptionRepository repository,
IExceptionNotificationService notificationService,
TimeSpan horizon,
CancellationToken cancellationToken)
{
var expiring = await repository.GetExpiringAsync(horizon, cancellationToken);
if (expiring.Count == 0)
{
return 0;
}
_logger.LogDebug("Found {Count} exceptions expiring within {Horizon}", expiring.Count, horizon);
var now = _timeProvider.GetUtcNow();
var notifiedCount = 0;
foreach (var exception in expiring)
{
try
{
var timeUntilExpiry = exception.ExpiresAt - now;
// Only warn once per day threshold (1 day, 3 days, 7 days)
if (ShouldSendWarning(timeUntilExpiry))
{
await notificationService.NotifyExceptionExpiringSoonAsync(
exception, timeUntilExpiry, cancellationToken);
notifiedCount++;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to send expiry warning for exception {ExceptionId}",
exception.ExceptionId);
}
}
return notifiedCount;
}
private static bool ShouldSendWarning(TimeSpan timeUntilExpiry)
{
// Send warnings at specific thresholds
var days = (int)timeUntilExpiry.TotalDays;
// Warn at 7 days, 3 days, 1 day
return days is 7 or 3 or 1;
}
}

View File

@@ -0,0 +1,227 @@
// <copyright file="ExceptionQueryService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Gateway.Services;
/// <summary>
/// Service interface for optimized exception queries.
/// </summary>
public interface IExceptionQueryService
{
/// <summary>
/// Gets active exceptions that apply to a finding.
/// </summary>
/// <param name="scope">The scope to match against.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of applicable active exceptions.</returns>
Task<IReadOnlyList<ExceptionObject>> GetApplicableExceptionsAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions expiring within the given horizon.
/// </summary>
/// <param name="horizon">Time horizon for expiry check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of exceptions expiring soon.</returns>
Task<IReadOnlyList<ExceptionObject>> GetExpiringExceptionsAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions matching a specific scope.
/// </summary>
/// <param name="scope">The scope to match.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of matching exceptions.</returns>
Task<IReadOnlyList<ExceptionObject>> GetExceptionsByScopeAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a finding is covered by an active exception.
/// </summary>
/// <param name="vulnerabilityId">Vulnerability ID to check.</param>
/// <param name="purl">Package URL to check.</param>
/// <param name="environment">Environment to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The covering exception if found, null otherwise.</returns>
Task<ExceptionObject?> FindCoveringExceptionAsync(
string? vulnerabilityId,
string? purl,
string? environment,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates any cached exception data.
/// </summary>
void InvalidateCache();
}
/// <summary>
/// Implementation of exception query service with caching.
/// </summary>
public sealed class ExceptionQueryService : IExceptionQueryService
{
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(5);
private const string ActiveExceptionsCacheKey = "exceptions:active:all";
private readonly IExceptionRepository _repository;
private readonly IMemoryCache _cache;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionQueryService> _logger;
/// <summary>
/// Creates a new exception query service.
/// </summary>
public ExceptionQueryService(
IExceptionRepository repository,
IMemoryCache cache,
TimeProvider timeProvider,
ILogger<ExceptionQueryService> logger)
{
_repository = repository;
_cache = cache;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetApplicableExceptionsAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default)
{
// Get all active exceptions that could match this scope
var activeExceptions = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
// Filter by environment if specified in scope
if (scope.Environments.Length > 0)
{
activeExceptions = activeExceptions
.Where(e => e.Scope.Environments.Length == 0 ||
e.Scope.Environments.Any(env => scope.Environments.Contains(env)))
.ToList();
}
_logger.LogDebug(
"Found {Count} applicable exceptions for scope: vuln={VulnId}, purl={Purl}",
activeExceptions.Count,
scope.VulnerabilityId,
scope.PurlPattern);
return activeExceptions;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetExpiringExceptionsAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default)
{
return await _repository.GetExpiringAsync(horizon, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetExceptionsByScopeAsync(
ExceptionScope scope,
CancellationToken cancellationToken = default)
{
return await _repository.GetActiveByScopeAsync(scope, cancellationToken);
}
/// <inheritdoc />
public async Task<ExceptionObject?> FindCoveringExceptionAsync(
string? vulnerabilityId,
string? purl,
string? environment,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(vulnerabilityId) && string.IsNullOrEmpty(purl))
{
return null;
}
var scope = new ExceptionScope
{
VulnerabilityId = vulnerabilityId,
PurlPattern = purl,
Environments = string.IsNullOrEmpty(environment) ? [] : [environment]
};
var exceptions = await GetApplicableExceptionsAsync(scope, cancellationToken);
// Return the most specific matching exception
// Priority: exact PURL match > wildcard PURL > vulnerability-only
return exceptions
.OrderByDescending(e => GetSpecificityScore(e, vulnerabilityId, purl))
.FirstOrDefault();
}
/// <inheritdoc />
public void InvalidateCache()
{
_cache.Remove(ActiveExceptionsCacheKey);
_logger.LogDebug("Exception cache invalidated");
}
private static int GetSpecificityScore(ExceptionObject exception, string? vulnerabilityId, string? purl)
{
var score = 0;
// Exact vulnerability match
if (!string.IsNullOrEmpty(exception.Scope.VulnerabilityId) &&
exception.Scope.VulnerabilityId == vulnerabilityId)
{
score += 100;
}
// PURL matching
if (!string.IsNullOrEmpty(exception.Scope.PurlPattern) && !string.IsNullOrEmpty(purl))
{
if (exception.Scope.PurlPattern == purl)
{
score += 50; // Exact match
}
else if (MatchesPurlPattern(purl, exception.Scope.PurlPattern))
{
score += 25; // Wildcard match
}
}
// Artifact digest match (most specific)
if (!string.IsNullOrEmpty(exception.Scope.ArtifactDigest))
{
score += 200;
}
// Environment specificity
if (exception.Scope.Environments.Length > 0)
{
score += 10;
}
return score;
}
private static bool MatchesPurlPattern(string purl, string pattern)
{
// Simple wildcard matching: pkg:npm/lodash@* matches pkg:npm/lodash@4.17.21
if (!pattern.Contains('*'))
{
return pattern.Equals(purl, StringComparison.OrdinalIgnoreCase);
}
// Split on wildcard and check prefix match
var prefixEnd = pattern.IndexOf('*');
var prefix = pattern[..prefixEnd];
return purl.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,601 @@
// <copyright file="ExceptionService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Gateway.Services;
/// <summary>
/// Service for managing exception lifecycle with business logic validation.
/// </summary>
public sealed class ExceptionService : IExceptionService
{
private const int MinRationaleLength = 50;
private static readonly TimeSpan MaxExpiryHorizon = TimeSpan.FromDays(365);
private readonly IExceptionRepository _repository;
private readonly IExceptionNotificationService _notificationService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ExceptionService> _logger;
/// <summary>
/// Creates a new exception service.
/// </summary>
public ExceptionService(
IExceptionRepository repository,
IExceptionNotificationService notificationService,
TimeProvider timeProvider,
ILogger<ExceptionService> logger)
{
_repository = repository;
_notificationService = notificationService;
_timeProvider = timeProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task<ExceptionResult> CreateAsync(
CreateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
// Validate scope is specific enough
var scopeValidation = ValidateScope(request.Scope);
if (!scopeValidation.IsValid)
{
return ExceptionResult.Failure(ExceptionErrorCode.ScopeNotSpecific, scopeValidation.Error!);
}
// Validate expiry
var expiryValidation = ValidateExpiry(request.ExpiresAt, now);
if (!expiryValidation.IsValid)
{
return ExceptionResult.Failure(ExceptionErrorCode.ExpiryInvalid, expiryValidation.Error!);
}
// Validate rationale
if (string.IsNullOrWhiteSpace(request.Rationale) || request.Rationale.Length < MinRationaleLength)
{
return ExceptionResult.Failure(
ExceptionErrorCode.RationaleTooShort,
$"Rationale must be at least {MinRationaleLength} characters.");
}
var exceptionId = GenerateExceptionId();
var exception = new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = ExceptionStatus.Proposed,
Type = request.Type,
Scope = request.Scope,
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = now,
UpdatedAt = now,
ExpiresAt = request.ExpiresAt,
ReasonCode = request.ReasonCode,
Rationale = request.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? [],
TicketRef = request.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
};
try
{
var created = await _repository.CreateAsync(exception, actorId, clientInfo, cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} created by {ActorId} for {Type}",
exceptionId, actorId, request.Type);
await _notificationService.NotifyExceptionCreatedAsync(created, cancellationToken);
return ExceptionResult.Success(created);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create exception for {ActorId}", actorId);
throw;
}
}
/// <inheritdoc />
public async Task<ExceptionResult> UpdateAsync(
string exceptionId,
UpdateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Check state allows updates
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Cannot update an expired or revoked exception.");
}
// Validate rationale if provided
if (request.Rationale is not null && request.Rationale.Length < MinRationaleLength)
{
return ExceptionResult.Failure(
ExceptionErrorCode.RationaleTooShort,
$"Rationale must be at least {MinRationaleLength} characters.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = now,
Rationale = request.Rationale ?? existing.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? existing.EvidenceRefs,
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? existing.CompensatingControls,
TicketRef = request.TicketRef ?? existing.TicketRef,
Metadata = request.Metadata?.ToImmutableDictionary() ?? existing.Metadata
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Updated,
actorId,
"Exception updated",
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} updated by {ActorId}",
exceptionId, actorId);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> ApproveAsync(
string exceptionId,
string? comment,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state transition
if (existing.Status != ExceptionStatus.Proposed)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Only proposed exceptions can be approved.");
}
// Validate approver is not requester
if (actorId == existing.RequesterId)
{
return ExceptionResult.Failure(
ExceptionErrorCode.SelfApprovalNotAllowed,
"Requester cannot approve their own exception.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = now,
ApprovedAt = now,
ApproverIds = existing.ApproverIds.Add(actorId)
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Approved,
actorId,
comment ?? "Exception approved",
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} approved by {ActorId}",
exceptionId, actorId);
await _notificationService.NotifyExceptionApprovedAsync(result, actorId, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> ActivateAsync(
string exceptionId,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state transition
if (existing.Status != ExceptionStatus.Approved)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Only approved exceptions can be activated.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Active,
UpdatedAt = now
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Activated,
actorId,
"Exception activated",
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} activated by {ActorId}",
exceptionId, actorId);
await _notificationService.NotifyExceptionActivatedAsync(result, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> ExtendAsync(
string exceptionId,
DateTimeOffset newExpiresAt,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state
if (existing.Status != ExceptionStatus.Active)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Only active exceptions can be extended.");
}
// Validate new expiry is after current
if (newExpiresAt <= existing.ExpiresAt)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ExpiryInvalid,
"New expiry must be after current expiry.");
}
// Validate reason length
if (string.IsNullOrWhiteSpace(reason) || reason.Length < 20)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ValidationFailed,
"Extension reason must be at least 20 characters.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = now,
ExpiresAt = newExpiresAt
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Extended,
actorId,
reason,
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} extended by {ActorId} to {NewExpiry}",
exceptionId, actorId, newExpiresAt);
await _notificationService.NotifyExceptionExtendedAsync(result, newExpiresAt, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionResult> RevokeAsync(
string exceptionId,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default)
{
var existing = await _repository.GetByIdAsync(exceptionId, cancellationToken);
if (existing is null)
{
return ExceptionResult.Failure(ExceptionErrorCode.NotFound, "Exception not found.");
}
// Validate state
if (existing.Status is ExceptionStatus.Expired or ExceptionStatus.Revoked)
{
return ExceptionResult.Failure(
ExceptionErrorCode.InvalidStateTransition,
"Exception is already expired or revoked.");
}
// Validate reason length
if (string.IsNullOrWhiteSpace(reason) || reason.Length < 10)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ValidationFailed,
"Revocation reason must be at least 10 characters.");
}
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Revoked,
UpdatedAt = now
};
try
{
var result = await _repository.UpdateAsync(
updated,
ExceptionEventType.Revoked,
actorId,
reason,
clientInfo,
cancellationToken);
_logger.LogInformation(
"Exception {ExceptionId} revoked by {ActorId}",
exceptionId, actorId);
await _notificationService.NotifyExceptionRevokedAsync(result, reason, cancellationToken);
return ExceptionResult.Success(result);
}
catch (ConcurrencyException)
{
return ExceptionResult.Failure(
ExceptionErrorCode.ConcurrencyConflict,
"Exception was modified by another user. Please refresh and try again.");
}
}
/// <inheritdoc />
public async Task<ExceptionObject?> GetByIdAsync(
string exceptionId,
CancellationToken cancellationToken = default)
{
return await _repository.GetByIdAsync(exceptionId, cancellationToken);
}
/// <inheritdoc />
public async Task<(IReadOnlyList<ExceptionObject> Items, int TotalCount)> ListAsync(
ExceptionFilter filter,
CancellationToken cancellationToken = default)
{
var items = await _repository.GetByFilterAsync(filter, cancellationToken);
var counts = await _repository.GetCountsAsync(filter.TenantId, cancellationToken);
return (items, counts.Total);
}
/// <inheritdoc />
public async Task<ExceptionCounts> GetCountsAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default)
{
return await _repository.GetCountsAsync(tenantId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExceptionObject>> GetExpiringAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default)
{
return await _repository.GetExpiringAsync(horizon, cancellationToken);
}
/// <inheritdoc />
public async Task<ExceptionHistory> GetHistoryAsync(
string exceptionId,
CancellationToken cancellationToken = default)
{
return await _repository.GetHistoryAsync(exceptionId, cancellationToken);
}
#region Validation Helpers
private static (bool IsValid, string? Error) ValidateScope(ExceptionScope scope)
{
// Scope must have at least one specific field
var hasArtifact = !string.IsNullOrEmpty(scope.ArtifactDigest);
var hasVulnerability = !string.IsNullOrEmpty(scope.VulnerabilityId);
var hasPurl = !string.IsNullOrEmpty(scope.PurlPattern);
var hasPolicy = !string.IsNullOrEmpty(scope.PolicyRuleId);
if (!hasArtifact && !hasVulnerability && !hasPurl && !hasPolicy)
{
return (false, "Exception scope must specify at least one of: artifactDigest, vulnerabilityId, purlPattern, or policyRuleId.");
}
// Validate PURL pattern if provided
if (hasPurl && !IsValidPurlPattern(scope.PurlPattern!))
{
return (false, "Invalid PURL pattern format. Must start with 'pkg:' and follow PURL specification.");
}
// Validate vulnerability ID format if provided
if (hasVulnerability && !IsValidVulnerabilityId(scope.VulnerabilityId!))
{
return (false, "Invalid vulnerability ID format. Must be CVE-XXXX-XXXXX, GHSA-xxxx-xxxx-xxxx, or similar.");
}
return (true, null);
}
private (bool IsValid, string? Error) ValidateExpiry(DateTimeOffset expiresAt, DateTimeOffset now)
{
if (expiresAt <= now)
{
return (false, "Expiry date must be in the future.");
}
if (expiresAt > now.Add(MaxExpiryHorizon))
{
return (false, $"Expiry date cannot be more than {MaxExpiryHorizon.Days} days in the future.");
}
return (true, null);
}
private static bool IsValidPurlPattern(string pattern)
{
// Basic PURL validation: must start with pkg: and have at least type/name
return pattern.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase) &&
pattern.Contains('/');
}
private static bool IsValidVulnerabilityId(string id)
{
// Accept CVE, GHSA, OSV, and other common formats
return id.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("GHSA-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("OSV-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("SNYK-", StringComparison.OrdinalIgnoreCase) ||
id.StartsWith("GO-", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateExceptionId()
{
// Format: EXC-{random alphanumeric}
return $"EXC-{Guid.NewGuid():N}"[..20];
}
#endregion
}
/// <summary>
/// Service for sending exception-related notifications.
/// </summary>
public interface IExceptionNotificationService
{
/// <summary>Notifies that an exception was created.</summary>
Task NotifyExceptionCreatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was approved.</summary>
Task NotifyExceptionApprovedAsync(ExceptionObject exception, string approverId, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was activated.</summary>
Task NotifyExceptionActivatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was extended.</summary>
Task NotifyExceptionExtendedAsync(ExceptionObject exception, DateTimeOffset newExpiry, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception was revoked.</summary>
Task NotifyExceptionRevokedAsync(ExceptionObject exception, string reason, CancellationToken cancellationToken = default);
/// <summary>Notifies that an exception is expiring soon.</summary>
Task NotifyExceptionExpiringSoonAsync(ExceptionObject exception, TimeSpan timeUntilExpiry, CancellationToken cancellationToken = default);
}
/// <summary>
/// No-op implementation of exception notification service.
/// </summary>
public sealed class NoOpExceptionNotificationService : IExceptionNotificationService
{
/// <inheritdoc />
public Task NotifyExceptionCreatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionApprovedAsync(ExceptionObject exception, string approverId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionActivatedAsync(ExceptionObject exception, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionExtendedAsync(ExceptionObject exception, DateTimeOffset newExpiry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionRevokedAsync(ExceptionObject exception, string reason, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <inheritdoc />
public Task NotifyExceptionExpiringSoonAsync(ExceptionObject exception, TimeSpan timeUntilExpiry, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,234 @@
// <copyright file="IExceptionService.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
namespace StellaOps.Policy.Gateway.Services;
/// <summary>
/// Service for managing exception lifecycle with business logic validation.
/// </summary>
public interface IExceptionService
{
/// <summary>
/// Creates a new exception with validation.
/// </summary>
/// <param name="request">Creation request details.</param>
/// <param name="actorId">ID of the user creating the exception.</param>
/// <param name="clientInfo">Client info for audit trail.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing created exception or validation errors.</returns>
Task<ExceptionResult> CreateAsync(
CreateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing exception.
/// </summary>
Task<ExceptionResult> UpdateAsync(
string exceptionId,
UpdateExceptionCommand request,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Approves a proposed exception.
/// </summary>
Task<ExceptionResult> ApproveAsync(
string exceptionId,
string? comment,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Activates an approved exception.
/// </summary>
Task<ExceptionResult> ActivateAsync(
string exceptionId,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Extends an active exception's expiry date.
/// </summary>
Task<ExceptionResult> ExtendAsync(
string exceptionId,
DateTimeOffset newExpiresAt,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Revokes an exception.
/// </summary>
Task<ExceptionResult> RevokeAsync(
string exceptionId,
string reason,
string actorId,
string? clientInfo = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an exception by ID.
/// </summary>
Task<ExceptionObject?> GetByIdAsync(
string exceptionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists exceptions with filtering.
/// </summary>
Task<(IReadOnlyList<ExceptionObject> Items, int TotalCount)> ListAsync(
ExceptionFilter filter,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exception counts summary.
/// </summary>
Task<ExceptionCounts> GetCountsAsync(
Guid? tenantId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exceptions expiring within the given horizon.
/// </summary>
Task<IReadOnlyList<ExceptionObject>> GetExpiringAsync(
TimeSpan horizon,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets exception audit history.
/// </summary>
Task<ExceptionHistory> GetHistoryAsync(
string exceptionId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Command for creating an exception.
/// </summary>
public sealed record CreateExceptionCommand
{
/// <summary>Type of exception.</summary>
public required ExceptionType Type { get; init; }
/// <summary>Exception scope.</summary>
public required ExceptionScope Scope { get; init; }
/// <summary>Owner ID.</summary>
public required string OwnerId { get; init; }
/// <summary>Reason code.</summary>
public required ExceptionReason ReasonCode { get; init; }
/// <summary>Detailed rationale.</summary>
public required string Rationale { get; init; }
/// <summary>Expiry date.</summary>
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>Evidence references.</summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>Compensating controls.</summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>Ticket reference.</summary>
public string? TicketRef { get; init; }
/// <summary>Metadata.</summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Command for updating an exception.
/// </summary>
public sealed record UpdateExceptionCommand
{
/// <summary>Updated rationale.</summary>
public string? Rationale { get; init; }
/// <summary>Updated evidence references.</summary>
public IReadOnlyList<string>? EvidenceRefs { get; init; }
/// <summary>Updated compensating controls.</summary>
public IReadOnlyList<string>? CompensatingControls { get; init; }
/// <summary>Updated ticket reference.</summary>
public string? TicketRef { get; init; }
/// <summary>Updated metadata.</summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Result of an exception operation.
/// </summary>
public sealed record ExceptionResult
{
/// <summary>Whether the operation succeeded.</summary>
public bool IsSuccess { get; init; }
/// <summary>The exception object if successful.</summary>
public ExceptionObject? Exception { get; init; }
/// <summary>Error message if failed.</summary>
public string? Error { get; init; }
/// <summary>Error code for programmatic handling.</summary>
public ExceptionErrorCode? ErrorCode { get; init; }
/// <summary>Creates a success result.</summary>
public static ExceptionResult Success(ExceptionObject exception) => new()
{
IsSuccess = true,
Exception = exception
};
/// <summary>Creates a failure result.</summary>
public static ExceptionResult Failure(ExceptionErrorCode code, string error) => new()
{
IsSuccess = false,
ErrorCode = code,
Error = error
};
}
/// <summary>
/// Error codes for exception operations.
/// </summary>
public enum ExceptionErrorCode
{
/// <summary>Exception not found.</summary>
NotFound,
/// <summary>Validation failed.</summary>
ValidationFailed,
/// <summary>Invalid state transition.</summary>
InvalidStateTransition,
/// <summary>Self-approval not allowed.</summary>
SelfApprovalNotAllowed,
/// <summary>Concurrency conflict.</summary>
ConcurrencyConflict,
/// <summary>Scope not specific enough.</summary>
ScopeNotSpecific,
/// <summary>Expiry invalid.</summary>
ExpiryInvalid,
/// <summary>Rationale too short.</summary>
RationaleTooShort
}

View File

@@ -17,9 +17,13 @@
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../StellaOps.Policy.Scoring/StellaOps.Policy.Scoring.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Policy.Storage.Postgres/StellaOps.Policy.Storage.Postgres.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
</ItemGroup>
</Project>

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>

View File

@@ -0,0 +1,395 @@
using System.Collections.Immutable;
using FluentAssertions;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Exceptions.Services;
using Xunit;
namespace StellaOps.Policy.Exceptions.Tests;
/// <summary>
/// Unit tests for ExceptionEvaluator service.
/// </summary>
public sealed class ExceptionEvaluatorTests
{
private readonly Mock<IExceptionRepository> _repositoryMock;
private readonly ExceptionEvaluator _evaluator;
public ExceptionEvaluatorTests()
{
_repositoryMock = new Mock<IExceptionRepository>();
_evaluator = new ExceptionEvaluator(_repositoryMock.Object);
}
[Fact]
public async Task EvaluateAsync_WhenNoExceptionsFound_ShouldReturnNoMatch()
{
// Arrange
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeFalse();
result.MatchingExceptions.Should().BeEmpty();
result.PrimaryReason.Should().BeNull();
result.PrimaryRationale.Should().BeNull();
}
[Fact]
public async Task EvaluateAsync_WhenExceptionMatchesVulnerability_ShouldReturnMatch()
{
// Arrange
var exception = CreateException(
vulnerabilityId: "CVE-2024-12345",
reason: ExceptionReason.FalsePositive,
rationale: "This is a false positive confirmed by manual analysis of the codebase.");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
result.MatchingExceptions.Should().HaveCount(1);
result.PrimaryReason.Should().Be(ExceptionReason.FalsePositive);
result.PrimaryRationale.Should().Contain("false positive");
}
[Fact]
public async Task EvaluateAsync_WhenExceptionMatchesArtifactDigest_ShouldReturnMatch()
{
// Arrange
var digest = "sha256:abc123def456";
var exception = CreateException(artifactDigest: digest);
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
ArtifactDigest = digest
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
result.MatchingExceptions.Should().HaveCount(1);
}
[Fact]
public async Task EvaluateAsync_WhenExceptionMatchesPolicyRule_ShouldReturnMatch()
{
// Arrange
var exception = CreateException(policyRuleId: "no-root-containers");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
PolicyRuleId = "no-root-containers"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
}
[Fact]
public async Task EvaluateAsync_WhenExceptionHasWrongVulnerabilityId_ShouldNotMatch()
{
// Arrange
var exception = CreateException(vulnerabilityId: "CVE-2024-99999");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeFalse();
}
[Fact]
public async Task EvaluateAsync_WhenExceptionHasWrongArtifactDigest_ShouldNotMatch()
{
// Arrange
var exception = CreateException(artifactDigest: "sha256:wrongdigest");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
ArtifactDigest = "sha256:correctdigest"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeFalse();
}
[Fact]
public async Task EvaluateAsync_WhenEnvironmentDoesNotMatch_ShouldNotMatch()
{
// Arrange
var exception = CreateException(
vulnerabilityId: "CVE-2024-12345",
environments: ["staging", "dev"]);
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "prod"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeFalse();
}
[Fact]
public async Task EvaluateAsync_WhenEnvironmentMatches_ShouldReturnMatch()
{
// Arrange
var exception = CreateException(
vulnerabilityId: "CVE-2024-12345",
environments: ["staging", "dev", "prod"]);
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "prod"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
}
[Fact]
public async Task EvaluateAsync_WhenExceptionHasEmptyEnvironments_ShouldMatchAny()
{
// Arrange
var exception = CreateException(
vulnerabilityId: "CVE-2024-12345",
environments: []);
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "any-environment"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
}
[Fact]
public async Task EvaluateAsync_WithMultipleMatchingExceptions_ShouldReturnMostSpecificFirst()
{
// Arrange
var broadException = CreateException(
exceptionId: "EXC-BROAD",
vulnerabilityId: "CVE-2024-12345");
var specificException = CreateException(
exceptionId: "EXC-SPECIFIC",
vulnerabilityId: "CVE-2024-12345",
artifactDigest: "sha256:abc123");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([broadException, specificException]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
ArtifactDigest = "sha256:abc123"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
result.MatchingExceptions.Should().HaveCount(2);
// Most specific should be first (has more scope constraints)
result.MatchingExceptions[0].ExceptionId.Should().Be("EXC-SPECIFIC");
}
[Fact]
public async Task EvaluateAsync_ShouldCollectAllEvidenceRefs()
{
// Arrange
var exception1 = CreateException(
exceptionId: "EXC-1",
vulnerabilityId: "CVE-2024-12345",
evidenceRefs: ["sha256:evidence1"]);
var exception2 = CreateException(
exceptionId: "EXC-2",
vulnerabilityId: "CVE-2024-12345",
evidenceRefs: ["sha256:evidence2", "sha256:evidence3"]);
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception1, exception2]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.AllEvidenceRefs.Should().HaveCount(3);
result.AllEvidenceRefs.Should().Contain(["sha256:evidence1", "sha256:evidence2", "sha256:evidence3"]);
}
[Fact]
public async Task EvaluateBatchAsync_ShouldEvaluateAllContexts()
{
// Arrange
var exception = CreateException(vulnerabilityId: "CVE-2024-12345");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionScope scope, CancellationToken _) =>
scope.VulnerabilityId == "CVE-2024-12345" ? [exception] : []);
var contexts = new List<FindingContext>
{
new() { VulnerabilityId = "CVE-2024-12345" },
new() { VulnerabilityId = "CVE-2024-99999" },
new() { VulnerabilityId = "CVE-2024-12345" }
};
// Act
var results = await _evaluator.EvaluateBatchAsync(contexts);
// Assert
results.Should().HaveCount(3);
results[0].HasException.Should().BeTrue();
results[1].HasException.Should().BeFalse();
results[2].HasException.Should().BeTrue();
}
[Fact]
public async Task EvaluateAsync_WhenPurlPatternMatchesExactly_ShouldReturnMatch()
{
// Arrange
var exception = CreateException(purlPattern: "pkg:npm/lodash@4.17.21");
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([exception]);
var context = new FindingContext
{
Purl = "pkg:npm/lodash@4.17.21"
};
// Act
var result = await _evaluator.EvaluateAsync(context);
// Assert
result.HasException.Should().BeTrue();
}
#region Test Helpers
private static ExceptionObject CreateException(
string? exceptionId = null,
string? vulnerabilityId = null,
string? artifactDigest = null,
string? policyRuleId = null,
string? purlPattern = null,
string[]? environments = null,
ExceptionReason reason = ExceptionReason.AcceptedRisk,
string? rationale = null,
string[]? evidenceRefs = null)
{
return new ExceptionObject
{
ExceptionId = exceptionId ?? $"EXC-{Guid.NewGuid():N}",
Version = 1,
Status = ExceptionStatus.Active,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = vulnerabilityId,
ArtifactDigest = artifactDigest,
PolicyRuleId = policyRuleId,
PurlPattern = purlPattern,
Environments = environments?.ToImmutableArray() ?? []
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
CreatedAt = DateTimeOffset.UtcNow.AddDays(-1),
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-1),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = reason,
Rationale = rationale ?? "This is a test rationale that meets the minimum character requirement of 50 characters.",
EvidenceRefs = evidenceRefs?.ToImmutableArray() ?? [],
CompensatingControls = []
};
}
#endregion
}

View File

@@ -0,0 +1,312 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Exceptions.Tests;
/// <summary>
/// Unit tests for ExceptionEvent model and factory methods.
/// </summary>
public sealed class ExceptionEventTests
{
[Fact]
public void ForCreated_ShouldCreateCorrectEvent()
{
// Arrange
var exceptionId = "EXC-TEST123";
var actorId = "user@example.com";
var description = "Test exception created";
var clientInfo = "192.168.1.1";
// Act
var evt = ExceptionEvent.ForCreated(exceptionId, actorId, description, clientInfo);
// Assert
evt.ExceptionId.Should().Be(exceptionId);
evt.ActorId.Should().Be(actorId);
evt.Description.Should().Be(description);
evt.ClientInfo.Should().Be(clientInfo);
evt.EventType.Should().Be(ExceptionEventType.Created);
evt.SequenceNumber.Should().Be(1);
evt.PreviousStatus.Should().BeNull();
evt.NewStatus.Should().Be(ExceptionStatus.Proposed);
evt.NewVersion.Should().Be(1);
evt.EventId.Should().NotBeEmpty();
evt.OccurredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void ForCreated_WithoutDescription_ShouldUseDefault()
{
// Act
var evt = ExceptionEvent.ForCreated("EXC-TEST", "actor");
// Assert
evt.Description.Should().Be("Exception created");
}
[Fact]
public void ForApproved_ShouldCreateCorrectEvent()
{
// Arrange
var exceptionId = "EXC-TEST123";
var sequenceNumber = 2;
var actorId = "approver@example.com";
var newVersion = 2;
var description = "Approved by security team";
// Act
var evt = ExceptionEvent.ForApproved(exceptionId, sequenceNumber, actorId, newVersion, description);
// Assert
evt.EventType.Should().Be(ExceptionEventType.Approved);
evt.ExceptionId.Should().Be(exceptionId);
evt.SequenceNumber.Should().Be(sequenceNumber);
evt.ActorId.Should().Be(actorId);
evt.NewVersion.Should().Be(newVersion);
evt.Description.Should().Be(description);
evt.PreviousStatus.Should().Be(ExceptionStatus.Proposed);
evt.NewStatus.Should().Be(ExceptionStatus.Approved);
}
[Fact]
public void ForApproved_WithoutDescription_ShouldIncludeActorId()
{
// Act
var evt = ExceptionEvent.ForApproved("EXC-TEST", 2, "approver@example.com", 2);
// Assert
evt.Description.Should().Contain("approver@example.com");
}
[Fact]
public void ForActivated_ShouldCreateCorrectEvent()
{
// Arrange
var exceptionId = "EXC-TEST123";
var sequenceNumber = 3;
var actorId = "admin@example.com";
var newVersion = 3;
var previousStatus = ExceptionStatus.Approved;
// Act
var evt = ExceptionEvent.ForActivated(exceptionId, sequenceNumber, actorId, newVersion, previousStatus);
// Assert
evt.EventType.Should().Be(ExceptionEventType.Activated);
evt.PreviousStatus.Should().Be(ExceptionStatus.Approved);
evt.NewStatus.Should().Be(ExceptionStatus.Active);
evt.Description.Should().Be("Exception activated");
}
[Fact]
public void ForRevoked_ShouldCreateCorrectEvent()
{
// Arrange
var exceptionId = "EXC-TEST123";
var sequenceNumber = 4;
var actorId = "admin@example.com";
var newVersion = 4;
var previousStatus = ExceptionStatus.Active;
var reason = "No longer needed";
// Act
var evt = ExceptionEvent.ForRevoked(exceptionId, sequenceNumber, actorId, newVersion, previousStatus, reason);
// Assert
evt.EventType.Should().Be(ExceptionEventType.Revoked);
evt.PreviousStatus.Should().Be(ExceptionStatus.Active);
evt.NewStatus.Should().Be(ExceptionStatus.Revoked);
evt.Description.Should().Contain(reason);
evt.Details.Should().ContainKey("reason");
evt.Details["reason"].Should().Be(reason);
}
[Fact]
public void ForExpired_ShouldCreateCorrectEvent()
{
// Arrange
var exceptionId = "EXC-TEST123";
var sequenceNumber = 5;
var newVersion = 5;
// Act
var evt = ExceptionEvent.ForExpired(exceptionId, sequenceNumber, newVersion);
// Assert
evt.EventType.Should().Be(ExceptionEventType.Expired);
evt.ActorId.Should().Be("system");
evt.PreviousStatus.Should().Be(ExceptionStatus.Active);
evt.NewStatus.Should().Be(ExceptionStatus.Expired);
evt.Description.Should().Be("Exception expired automatically");
}
[Fact]
public void ForExtended_ShouldCreateCorrectEvent()
{
// Arrange
var exceptionId = "EXC-TEST123";
var sequenceNumber = 6;
var actorId = "admin@example.com";
var newVersion = 6;
var previousExpiry = DateTimeOffset.UtcNow.AddDays(7);
var newExpiry = DateTimeOffset.UtcNow.AddDays(30);
var reason = "Extended due to ongoing dependency update";
// Act
var evt = ExceptionEvent.ForExtended(exceptionId, sequenceNumber, actorId, newVersion, previousExpiry, newExpiry, reason);
// Assert
evt.EventType.Should().Be(ExceptionEventType.Extended);
evt.PreviousStatus.Should().Be(ExceptionStatus.Active);
evt.NewStatus.Should().Be(ExceptionStatus.Active); // Status unchanged
evt.Description.Should().Be(reason);
evt.Details.Should().ContainKey("previous_expiry");
evt.Details.Should().ContainKey("new_expiry");
}
[Fact]
public void ForExtended_WithoutReason_ShouldIncludeDates()
{
// Arrange
var previousExpiry = DateTimeOffset.UtcNow.AddDays(7);
var newExpiry = DateTimeOffset.UtcNow.AddDays(30);
// Act
var evt = ExceptionEvent.ForExtended("EXC-TEST", 2, "actor", 2, previousExpiry, newExpiry);
// Assert
evt.Description.Should().Contain("extended from");
evt.Description.Should().Contain("to");
}
[Theory]
[InlineData(ExceptionEventType.Created)]
[InlineData(ExceptionEventType.Updated)]
[InlineData(ExceptionEventType.Approved)]
[InlineData(ExceptionEventType.Activated)]
[InlineData(ExceptionEventType.Extended)]
[InlineData(ExceptionEventType.Revoked)]
[InlineData(ExceptionEventType.Expired)]
[InlineData(ExceptionEventType.EvidenceAttached)]
[InlineData(ExceptionEventType.CompensatingControlAdded)]
[InlineData(ExceptionEventType.Rejected)]
public void ExceptionEventType_AllValues_ShouldBeRecognized(ExceptionEventType eventType)
{
// Assert
Enum.IsDefined(eventType).Should().BeTrue();
}
[Fact]
public void AllFactoryMethods_ShouldGenerateUniqueEventIds()
{
// Act
var events = new List<ExceptionEvent>
{
ExceptionEvent.ForCreated("EXC-1", "actor"),
ExceptionEvent.ForCreated("EXC-2", "actor"),
ExceptionEvent.ForApproved("EXC-1", 2, "actor", 2),
ExceptionEvent.ForActivated("EXC-1", 3, "actor", 3, ExceptionStatus.Approved),
ExceptionEvent.ForRevoked("EXC-1", 4, "actor", 4, ExceptionStatus.Active, "reason"),
ExceptionEvent.ForExpired("EXC-1", 5, 5)
};
// Assert
events.Select(e => e.EventId).Distinct().Should().HaveCount(events.Count);
}
[Fact]
public void AllFactoryMethods_ShouldSetOccurredAtToNow()
{
// Arrange
var before = DateTimeOffset.UtcNow;
// Act
var evt = ExceptionEvent.ForCreated("EXC-TEST", "actor");
var after = DateTimeOffset.UtcNow;
// Assert
evt.OccurredAt.Should().BeOnOrAfter(before);
evt.OccurredAt.Should().BeOnOrBefore(after);
}
}
/// <summary>
/// Unit tests for ExceptionHistory aggregation.
/// </summary>
public sealed class ExceptionHistoryTests
{
[Fact]
public void ExceptionHistory_WithEvents_ShouldCalculateCorrectStats()
{
// Arrange
var events = new[]
{
CreateEvent(1, DateTimeOffset.UtcNow.AddHours(-3)),
CreateEvent(2, DateTimeOffset.UtcNow.AddHours(-2)),
CreateEvent(3, DateTimeOffset.UtcNow.AddHours(-1))
}.ToImmutableArray();
// Act
var history = new ExceptionHistory
{
ExceptionId = "EXC-TEST",
Events = events
};
// Assert
history.EventCount.Should().Be(3);
history.FirstEventAt.Should().Be(events[0].OccurredAt);
history.LastEventAt.Should().Be(events[2].OccurredAt);
}
[Fact]
public void ExceptionHistory_WithNoEvents_ShouldReturnNullTimestamps()
{
// Arrange & Act
var history = new ExceptionHistory
{
ExceptionId = "EXC-TEST",
Events = []
};
// Assert
history.EventCount.Should().Be(0);
history.FirstEventAt.Should().BeNull();
history.LastEventAt.Should().BeNull();
}
[Fact]
public void ExceptionHistory_WithSingleEvent_ShouldHaveSameFirstAndLast()
{
// Arrange
var evt = CreateEvent(1, DateTimeOffset.UtcNow);
var history = new ExceptionHistory
{
ExceptionId = "EXC-TEST",
Events = [evt]
};
// Assert
history.EventCount.Should().Be(1);
history.FirstEventAt.Should().Be(evt.OccurredAt);
history.LastEventAt.Should().Be(evt.OccurredAt);
}
private static ExceptionEvent CreateEvent(int sequenceNumber, DateTimeOffset occurredAt)
{
return new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = "EXC-TEST",
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Updated,
ActorId = "actor",
OccurredAt = occurredAt,
NewStatus = ExceptionStatus.Active,
NewVersion = sequenceNumber
};
}
}

View File

@@ -0,0 +1,295 @@
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Exceptions.Tests;
/// <summary>
/// Unit tests for ExceptionObject domain model.
/// </summary>
public sealed class ExceptionObjectTests
{
[Fact]
public void ExceptionObject_WithValidScope_ShouldBeValid()
{
// Arrange & Act
var scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
};
// Assert
scope.IsValid.Should().BeTrue();
}
[Fact]
public void ExceptionScope_WithNoConstraints_ShouldBeInvalid()
{
// Arrange & Act
var scope = new ExceptionScope();
// Assert
scope.IsValid.Should().BeFalse();
}
[Fact]
public void ExceptionScope_WithArtifactDigest_ShouldBeValid()
{
// Arrange & Act
var scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123def456"
};
// Assert
scope.IsValid.Should().BeTrue();
}
[Fact]
public void ExceptionScope_WithPurlPattern_ShouldBeValid()
{
// Arrange & Act
var scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
};
// Assert
scope.IsValid.Should().BeTrue();
}
[Fact]
public void ExceptionScope_WithPolicyRuleId_ShouldBeValid()
{
// Arrange & Act
var scope = new ExceptionScope
{
PolicyRuleId = "no-root-containers"
};
// Assert
scope.IsValid.Should().BeTrue();
}
[Fact]
public void ExceptionObject_IsEffective_WhenActiveAndNotExpired_ShouldBeTrue()
{
// Arrange
var exception = CreateException(
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
// Act & Assert
exception.IsEffective.Should().BeTrue();
exception.HasExpired.Should().BeFalse();
}
[Fact]
public void ExceptionObject_IsEffective_WhenActiveButExpired_ShouldBeFalse()
{
// Arrange
var exception = CreateException(
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
// Act & Assert
exception.IsEffective.Should().BeFalse();
exception.HasExpired.Should().BeTrue();
}
[Fact]
public void ExceptionObject_IsEffective_WhenProposed_ShouldBeFalse()
{
// Arrange
var exception = CreateException(
status: ExceptionStatus.Proposed,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
// Act & Assert
exception.IsEffective.Should().BeFalse();
}
[Fact]
public void ExceptionObject_IsEffective_WhenRevoked_ShouldBeFalse()
{
// Arrange
var exception = CreateException(
status: ExceptionStatus.Revoked,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
// Act & Assert
exception.IsEffective.Should().BeFalse();
}
[Fact]
public void ExceptionObject_IsEffective_WhenExpiredStatus_ShouldBeFalse()
{
// Arrange
var exception = CreateException(
status: ExceptionStatus.Expired,
expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
// Act & Assert
exception.IsEffective.Should().BeFalse();
}
[Theory]
[InlineData(ExceptionStatus.Proposed)]
[InlineData(ExceptionStatus.Approved)]
[InlineData(ExceptionStatus.Active)]
[InlineData(ExceptionStatus.Expired)]
[InlineData(ExceptionStatus.Revoked)]
public void ExceptionStatus_AllValues_ShouldBeRecognized(ExceptionStatus status)
{
// Arrange
var exception = CreateException(status: status);
// Assert
exception.Status.Should().Be(status);
}
[Theory]
[InlineData(ExceptionType.Vulnerability)]
[InlineData(ExceptionType.Policy)]
[InlineData(ExceptionType.Unknown)]
[InlineData(ExceptionType.Component)]
public void ExceptionType_AllValues_ShouldBeRecognized(ExceptionType type)
{
// Arrange
var exception = CreateException(type: type);
// Assert
exception.Type.Should().Be(type);
}
[Theory]
[InlineData(ExceptionReason.FalsePositive)]
[InlineData(ExceptionReason.AcceptedRisk)]
[InlineData(ExceptionReason.CompensatingControl)]
[InlineData(ExceptionReason.TestOnly)]
[InlineData(ExceptionReason.VendorNotAffected)]
[InlineData(ExceptionReason.ScheduledFix)]
[InlineData(ExceptionReason.DeprecationInProgress)]
[InlineData(ExceptionReason.RuntimeMitigation)]
[InlineData(ExceptionReason.NetworkIsolation)]
[InlineData(ExceptionReason.Other)]
public void ExceptionReason_AllValues_ShouldBeRecognized(ExceptionReason reason)
{
// Arrange
var exception = CreateException(reason: reason);
// Assert
exception.ReasonCode.Should().Be(reason);
}
[Fact]
public void ExceptionObject_WithMultipleApprovers_ShouldStoreAll()
{
// Arrange
var approvers = ImmutableArray.Create("approver1", "approver2", "approver3");
var exception = CreateException(approverIds: approvers);
// Assert
exception.ApproverIds.Should().HaveCount(3);
exception.ApproverIds.Should().Contain(["approver1", "approver2", "approver3"]);
}
[Fact]
public void ExceptionObject_WithEvidenceRefs_ShouldStoreAll()
{
// Arrange
var evidenceRefs = ImmutableArray.Create(
"sha256:evidence1hash",
"sha256:evidence2hash");
var exception = CreateException(evidenceRefs: evidenceRefs);
// Assert
exception.EvidenceRefs.Should().HaveCount(2);
exception.EvidenceRefs.Should().Contain("sha256:evidence1hash");
}
[Fact]
public void ExceptionObject_WithMetadata_ShouldStoreKeyValuePairs()
{
// Arrange
var metadata = ImmutableDictionary<string, string>.Empty
.Add("team", "security")
.Add("priority", "high");
var exception = CreateException(metadata: metadata);
// Assert
exception.Metadata.Should().HaveCount(2);
exception.Metadata["team"].Should().Be("security");
exception.Metadata["priority"].Should().Be("high");
}
[Fact]
public void ExceptionScope_WithEnvironments_ShouldStoreAll()
{
// Arrange
var scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["prod", "staging", "dev"]
};
// Assert
scope.Environments.Should().HaveCount(3);
scope.Environments.Should().Contain(["prod", "staging", "dev"]);
}
[Fact]
public void ExceptionScope_WithTenantId_ShouldStoreValue()
{
// Arrange
var tenantId = Guid.NewGuid();
var scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
TenantId = tenantId
};
// Assert
scope.TenantId.Should().Be(tenantId);
}
#region Test Helpers
private static ExceptionObject CreateException(
ExceptionStatus status = ExceptionStatus.Active,
ExceptionType type = ExceptionType.Vulnerability,
ExceptionReason reason = ExceptionReason.AcceptedRisk,
DateTimeOffset? expiresAt = null,
ImmutableArray<string>? approverIds = null,
ImmutableArray<string>? evidenceRefs = null,
ImmutableDictionary<string, string>? metadata = null)
{
return new ExceptionObject
{
ExceptionId = $"EXC-{Guid.NewGuid():N}",
Version = 1,
Status = status,
Type = type,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
ApproverIds = approverIds ?? [],
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = reason,
Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.",
EvidenceRefs = evidenceRefs ?? [],
CompensatingControls = [],
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty
};
}
#endregion
}

View File

@@ -0,0 +1,26 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.Policy.Exceptions.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,300 @@
// <copyright file="ApprovalWorkflowServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using MsOptions = Microsoft.Extensions.Options.Options;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests.Services;
public class ApprovalWorkflowServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly Mock<IExceptionNotificationService> _notificationMock;
private readonly ApprovalWorkflowOptions _options;
private readonly ApprovalWorkflowService _service;
private readonly DateTimeOffset _now = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
public ApprovalWorkflowServiceTests()
{
_timeProvider = new FakeTimeProvider(_now);
_notificationMock = new Mock<IExceptionNotificationService>();
_options = new ApprovalWorkflowOptions();
_service = new ApprovalWorkflowService(
MsOptions.Create(_options),
_timeProvider,
_notificationMock.Object,
NullLogger<ApprovalWorkflowService>.Instance);
}
[Theory]
[InlineData("dev")]
[InlineData("Dev")]
[InlineData("DEV")]
public void GetPolicyForEnvironment_Dev_ReturnsDevPolicy(string env)
{
// Act
var policy = _service.GetPolicyForEnvironment(env);
// Assert
policy.RequiredApprovers.Should().Be(0);
policy.RequesterCanApprove.Should().BeTrue();
policy.AutoApprove.Should().BeTrue();
}
[Fact]
public void GetPolicyForEnvironment_Staging_ReturnsStagingPolicy()
{
// Act
var policy = _service.GetPolicyForEnvironment("staging");
// Assert
policy.RequiredApprovers.Should().Be(1);
policy.RequesterCanApprove.Should().BeFalse();
policy.AutoApprove.Should().BeFalse();
}
[Fact]
public void GetPolicyForEnvironment_Prod_ReturnsProdPolicy()
{
// Act
var policy = _service.GetPolicyForEnvironment("prod");
// Assert
policy.RequiredApprovers.Should().Be(2);
policy.RequesterCanApprove.Should().BeFalse();
policy.AllowedApproverRoles.Should().Contain("security-lead");
}
[Fact]
public void GetPolicyForEnvironment_Unknown_ReturnsDefaultPolicy()
{
// Act
var policy = _service.GetPolicyForEnvironment("unknown-env");
// Assert
policy.Should().Be(_options.DefaultPolicy);
}
[Fact]
public void ValidateApproval_SelfApprovalInProd_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "prod");
// Act
var result = _service.ValidateApproval(exception, "requester-123");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("cannot approve their own");
}
[Fact]
public void ValidateApproval_SelfApprovalInDev_ReturnsValid()
{
// Arrange
var exception = CreateException("requester-123", "dev");
// Act
var result = _service.ValidateApproval(exception, "requester-123");
// Assert
result.IsValid.Should().BeTrue();
result.IsComplete.Should().BeTrue(); // Dev requires 0 approvers
}
[Fact]
public void ValidateApproval_AlreadyApproved_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
ApproverIds = ["approver-456"]
};
// Act
var result = _service.ValidateApproval(exception, "approver-456");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("already approved");
}
[Fact]
public void ValidateApproval_MissingRequiredRole_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "prod");
var approverRoles = new List<string> { "developer" };
// Act
var result = _service.ValidateApproval(exception, "approver-456", approverRoles);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("security-lead");
}
[Fact]
public void ValidateApproval_WithRequiredRole_ReturnsValid()
{
// Arrange
var exception = CreateException("requester-123", "prod");
var approverRoles = new List<string> { "security-lead" };
// Act
var result = _service.ValidateApproval(exception, "approver-456", approverRoles);
// Assert
result.IsValid.Should().BeTrue();
result.IsComplete.Should().BeFalse(); // Prod requires 2 approvers
result.ApprovalsRemaining.Should().Be(1);
}
[Fact]
public void ValidateApproval_SecondApprovalInProd_ReturnsComplete()
{
// Arrange
var exception = CreateException("requester-123", "prod") with
{
ApproverIds = ["approver-111"]
};
var approverRoles = new List<string> { "security-admin" };
// Act
var result = _service.ValidateApproval(exception, "approver-222", approverRoles);
// Assert
result.IsValid.Should().BeTrue();
result.IsComplete.Should().BeTrue();
result.ApprovalsRemaining.Should().Be(0);
}
[Fact]
public void ValidateApproval_ExpiredDeadline_ReturnsInvalid()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = _now.AddDays(-30) // Way past staging deadline of 14 days
};
// Act
var result = _service.ValidateApproval(exception, "approver-456");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("deadline has passed");
}
[Fact]
public void ShouldAutoApprove_DevEnvironment_ReturnsTrue()
{
// Arrange
var exception = CreateException("requester-123", "dev");
// Act
var result = _service.ShouldAutoApprove(exception);
// Assert
result.Should().BeTrue();
}
[Fact]
public void ShouldAutoApprove_ProdEnvironment_ReturnsFalse()
{
// Arrange
var exception = CreateException("requester-123", "prod");
// Act
var result = _service.ShouldAutoApprove(exception);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsApprovalExpired_WithinDeadline_ReturnsFalse()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = _now.AddDays(-5)
};
// Act
var result = _service.IsApprovalExpired(exception);
// Assert
result.Should().BeFalse();
}
[Fact]
public void IsApprovalExpired_PastDeadline_ReturnsTrue()
{
// Arrange
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = _now.AddDays(-20)
};
// Act
var result = _service.IsApprovalExpired(exception);
// Assert
result.Should().BeTrue();
}
[Fact]
public void GetApprovalDeadline_ReturnsCorrectDeadline()
{
// Arrange
var createdAt = _now.AddDays(-5);
var exception = CreateException("requester-123", "staging") with
{
CreatedAt = createdAt
};
// Act
var deadline = _service.GetApprovalDeadline(exception);
// Assert
deadline.Should().Be(createdAt.AddDays(14)); // Staging deadline is 14 days
}
#region Helpers
private ExceptionObject CreateException(string requesterId, string environment) => new()
{
ExceptionId = "EXC-123",
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = [environment]
},
OwnerId = "owner-team",
RequesterId = requesterId,
CreatedAt = _now.AddDays(-1),
UpdatedAt = _now.AddDays(-1),
ExpiresAt = _now.AddDays(30),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "Test rationale",
EvidenceRefs = [],
CompensatingControls = [],
Metadata = ImmutableDictionary<string, string>.Empty
};
#endregion
}

View File

@@ -0,0 +1,337 @@
// <copyright file="ExceptionServiceTests.cs" company="StellaOps">
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later license.
// </copyright>
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Gateway.Services;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests.Services;
public class ExceptionServiceTests
{
private readonly Mock<IExceptionRepository> _repositoryMock;
private readonly Mock<IExceptionNotificationService> _notificationMock;
private readonly FakeTimeProvider _timeProvider;
private readonly ExceptionService _service;
private readonly DateTimeOffset _now = new(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
public ExceptionServiceTests()
{
_repositoryMock = new Mock<IExceptionRepository>();
_notificationMock = new Mock<IExceptionNotificationService>();
_timeProvider = new FakeTimeProvider(_now);
_service = new ExceptionService(
_repositoryMock.Object,
_notificationMock.Object,
_timeProvider,
NullLogger<ExceptionService>.Instance);
}
[Fact]
public async Task CreateAsync_WithValidRequest_CreatesException()
{
// Arrange
var command = CreateValidCommand();
_repositoryMock
.Setup(r => r.CreateAsync(It.IsAny<ExceptionObject>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject ex, string _, string? _, CancellationToken _) => ex);
// Act
var result = await _service.CreateAsync(command, "user-123", "client-info");
// Assert
result.IsSuccess.Should().BeTrue();
result.Exception.Should().NotBeNull();
result.Exception!.Status.Should().Be(ExceptionStatus.Proposed);
result.Exception.RequesterId.Should().Be("user-123");
result.Exception.OwnerId.Should().Be(command.OwnerId);
result.Exception.Rationale.Should().Be(command.Rationale);
_notificationMock.Verify(n => n.NotifyExceptionCreatedAsync(It.IsAny<ExceptionObject>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateAsync_WithEmptyScope_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
Scope = new ExceptionScope()
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ScopeNotSpecific);
result.Error.Should().Contain("scope must specify at least one");
}
[Fact]
public async Task CreateAsync_WithPastExpiry_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
ExpiresAt = _now.AddDays(-1)
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
result.Error.Should().Contain("future");
}
[Fact]
public async Task CreateAsync_WithExpiryTooFar_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
ExpiresAt = _now.AddDays(400) // More than 365 days
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
result.Error.Should().Contain("365 days");
}
[Fact]
public async Task CreateAsync_WithShortRationale_ReturnsValidationError()
{
// Arrange
var command = CreateValidCommand() with
{
Rationale = "Too short"
};
// Act
var result = await _service.CreateAsync(command, "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.RationaleTooShort);
result.Error.Should().Contain("50 characters");
}
[Fact]
public async Task ApproveAsync_WhenSelfApproval_ReturnsSelfApprovalError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ApproveAsync("EXC-123", null, "requester-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.SelfApprovalNotAllowed);
}
[Fact]
public async Task ApproveAsync_WhenNotProposed_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ApproveAsync("EXC-123", null, "approver-456");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task ApproveAsync_WithValidApprover_ApprovesException()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
_repositoryMock.Setup(r => r.UpdateAsync(It.IsAny<ExceptionObject>(), It.IsAny<ExceptionEventType>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject ex, ExceptionEventType _, string _, string? _, string? _, CancellationToken _) => ex);
// Act
var result = await _service.ApproveAsync("EXC-123", "Looks good", "approver-456");
// Assert
result.IsSuccess.Should().BeTrue();
result.Exception!.Status.Should().Be(ExceptionStatus.Approved);
result.Exception.ApproverIds.Should().Contain("approver-456");
_notificationMock.Verify(n => n.NotifyExceptionApprovedAsync(It.IsAny<ExceptionObject>(), "approver-456", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ActivateAsync_WhenNotApproved_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ActivateAsync("EXC-123", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task ActivateAsync_WhenApproved_ActivatesException()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Approved, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
_repositoryMock.Setup(r => r.UpdateAsync(It.IsAny<ExceptionObject>(), It.IsAny<ExceptionEventType>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject ex, ExceptionEventType _, string _, string? _, string? _, CancellationToken _) => ex);
// Act
var result = await _service.ActivateAsync("EXC-123", "user-123");
// Assert
result.IsSuccess.Should().BeTrue();
result.Exception!.Status.Should().Be(ExceptionStatus.Active);
}
[Fact]
public async Task RevokeAsync_WhenAlreadyRevoked_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Revoked, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.RevokeAsync("EXC-123", "Not needed anymore", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task RevokeAsync_WithShortReason_ReturnsValidationError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.RevokeAsync("EXC-123", "Too short", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ValidationFailed);
}
[Fact]
public async Task ExtendAsync_WhenNotActive_ReturnsInvalidStateError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Proposed, "requester-123");
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ExtendAsync("EXC-123", _now.AddDays(90), "Need more time to remediate", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.InvalidStateTransition);
}
[Fact]
public async Task ExtendAsync_WhenNewExpiryBeforeCurrent_ReturnsInvalidExpiryError()
{
// Arrange
var exception = CreateExceptionObject(ExceptionStatus.Active, "requester-123") with
{
ExpiresAt = _now.AddDays(30)
};
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(exception);
// Act
var result = await _service.ExtendAsync("EXC-123", _now.AddDays(15), "Need more time to remediate", "user-123");
// Assert
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ExceptionErrorCode.ExpiryInvalid);
}
[Fact]
public async Task GetByIdAsync_WhenNotFound_ReturnsNull()
{
// Arrange
_repositoryMock.Setup(r => r.GetByIdAsync("EXC-999", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExceptionObject?)null);
// Act
var result = await _service.GetByIdAsync("EXC-999");
// Assert
result.Should().BeNull();
}
#region Helpers
private CreateExceptionCommand CreateValidCommand() => new()
{
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
},
OwnerId = "owner-team",
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This vulnerability is a false positive because the vulnerable code path is not reachable in our deployment configuration.",
ExpiresAt = _now.AddDays(30)
};
private ExceptionObject CreateExceptionObject(ExceptionStatus status, string requesterId) => new()
{
ExceptionId = "EXC-123",
Version = 1,
Status = status,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" },
OwnerId = "owner-team",
RequesterId = requesterId,
CreatedAt = _now.AddDays(-1),
UpdatedAt = _now.AddDays(-1),
ExpiresAt = _now.AddDays(30),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "Test rationale that is long enough to pass validation requirements.",
EvidenceRefs = [],
CompensatingControls = [],
Metadata = ImmutableDictionary<string, string>.Empty
};
#endregion
}

View File

@@ -4,9 +4,26 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.Policy.Gateway/StellaOps.Policy.Gateway.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="8.0.1" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,466 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for PostgresExceptionObjectRepository.
/// Tests the new auditable exception objects against PostgreSQL.
/// </summary>
[Collection(PolicyPostgresCollection.Name)]
public sealed class ExceptionObjectRepositoryTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly PostgresExceptionObjectRepository _repository;
public ExceptionObjectRepositoryTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task CreateAsync_ShouldPersistExceptionAndCreateEvent()
{
// Arrange
var exception = CreateException("EXC-CREATE-001");
// Act
var created = await _repository.CreateAsync(exception, "test-actor", "127.0.0.1");
// Assert
created.Should().NotBeNull();
created.ExceptionId.Should().Be("EXC-CREATE-001");
created.Version.Should().Be(1);
// Verify event was created
var history = await _repository.GetHistoryAsync("EXC-CREATE-001");
history.Events.Should().HaveCount(1);
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
history.Events[0].ActorId.Should().Be("test-actor");
}
[Fact]
public async Task GetByIdAsync_WhenExists_ShouldReturnException()
{
// Arrange
var exception = CreateException("EXC-GETBYID-001");
await _repository.CreateAsync(exception, "test-actor");
// Act
var fetched = await _repository.GetByIdAsync("EXC-GETBYID-001");
// Assert
fetched.Should().NotBeNull();
fetched!.ExceptionId.Should().Be("EXC-GETBYID-001");
fetched.Status.Should().Be(ExceptionStatus.Proposed);
fetched.Type.Should().Be(ExceptionType.Vulnerability);
fetched.Scope.VulnerabilityId.Should().Be("CVE-2024-12345");
fetched.OwnerId.Should().Be("owner@example.com");
fetched.ReasonCode.Should().Be(ExceptionReason.AcceptedRisk);
}
[Fact]
public async Task GetByIdAsync_WhenNotExists_ShouldReturnNull()
{
// Act
var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT");
// Assert
fetched.Should().BeNull();
}
[Fact]
public async Task UpdateAsync_ShouldIncrementVersionAndCreateEvent()
{
// Arrange
var exception = CreateException("EXC-UPDATE-001");
await _repository.CreateAsync(exception, "creator");
var updated = exception with
{
Version = 2,
Status = ExceptionStatus.Approved,
ApprovedAt = DateTimeOffset.UtcNow,
ApproverIds = ["approver@example.com"],
UpdatedAt = DateTimeOffset.UtcNow
};
// Act
var result = await _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver@example.com", "Approved by security team");
// Assert
result.Version.Should().Be(2);
result.Status.Should().Be(ExceptionStatus.Approved);
var fetched = await _repository.GetByIdAsync("EXC-UPDATE-001");
fetched!.Version.Should().Be(2);
fetched.Status.Should().Be(ExceptionStatus.Approved);
// Verify events
var history = await _repository.GetHistoryAsync("EXC-UPDATE-001");
history.Events.Should().HaveCount(2);
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
}
[Fact]
public async Task UpdateAsync_WithConcurrencyConflict_ShouldThrow()
{
// Arrange
var exception = CreateException("EXC-CONCURRENCY-001");
await _repository.CreateAsync(exception, "creator");
// Simulate stale version
var staleUpdate = exception with
{
Version = 5, // Wrong version - should be 2
UpdatedAt = DateTimeOffset.UtcNow
};
// Act & Assert
await Assert.ThrowsAsync<ConcurrencyException>(() =>
_repository.UpdateAsync(staleUpdate, ExceptionEventType.Updated, "updater"));
}
[Fact]
public async Task GetByFilterAsync_ShouldFilterByStatus()
{
// Arrange
var proposed = CreateException("EXC-FILTER-001", status: ExceptionStatus.Proposed);
var active = CreateException("EXC-FILTER-002", status: ExceptionStatus.Active);
var revoked = CreateException("EXC-FILTER-003", status: ExceptionStatus.Revoked);
await _repository.CreateAsync(proposed, "actor");
await _repository.CreateAsync(active, "actor");
await _repository.CreateAsync(revoked, "actor");
// Act
var filter = new ExceptionFilter { Status = ExceptionStatus.Proposed };
var results = await _repository.GetByFilterAsync(filter);
// Assert
results.Should().ContainSingle();
results[0].ExceptionId.Should().Be("EXC-FILTER-001");
}
[Fact]
public async Task GetByFilterAsync_ShouldFilterByType()
{
// Arrange
var vuln = CreateException("EXC-TYPE-001", type: ExceptionType.Vulnerability);
var policy = CreateException("EXC-TYPE-002", type: ExceptionType.Policy);
await _repository.CreateAsync(vuln, "actor");
await _repository.CreateAsync(policy, "actor");
// Act
var filter = new ExceptionFilter { Type = ExceptionType.Policy };
var results = await _repository.GetByFilterAsync(filter);
// Assert
results.Should().ContainSingle();
results[0].ExceptionId.Should().Be("EXC-TYPE-002");
}
[Fact]
public async Task GetByFilterAsync_ShouldFilterByVulnerabilityId()
{
// Arrange
var exc1 = CreateException("EXC-VID-001", vulnerabilityId: "CVE-2024-11111");
var exc2 = CreateException("EXC-VID-002", vulnerabilityId: "CVE-2024-22222");
await _repository.CreateAsync(exc1, "actor");
await _repository.CreateAsync(exc2, "actor");
// Act
var filter = new ExceptionFilter { VulnerabilityId = "CVE-2024-11111" };
var results = await _repository.GetByFilterAsync(filter);
// Assert
results.Should().ContainSingle();
results[0].ExceptionId.Should().Be("EXC-VID-001");
}
[Fact]
public async Task GetByFilterAsync_ShouldSupportPagination()
{
// Arrange
for (int i = 0; i < 5; i++)
{
await _repository.CreateAsync(CreateException($"EXC-PAGE-{i:D3}"), "actor");
}
// Act
var filter = new ExceptionFilter { Limit = 2, Offset = 2 };
var results = await _repository.GetByFilterAsync(filter);
// Assert
results.Should().HaveCount(2);
}
[Fact]
public async Task GetActiveByScopeAsync_ShouldMatchVulnerabilityId()
{
// Arrange
var exception = CreateException("EXC-SCOPE-001", vulnerabilityId: "CVE-2024-99999", status: ExceptionStatus.Active);
await _repository.CreateAsync(exception, "actor");
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-99999" };
// Act
var results = await _repository.GetActiveByScopeAsync(scope);
// Assert
results.Should().ContainSingle();
results[0].ExceptionId.Should().Be("EXC-SCOPE-001");
}
[Fact]
public async Task GetActiveByScopeAsync_ShouldExcludeInactiveExceptions()
{
// Arrange
var proposed = CreateException("EXC-INACTIVE-001", vulnerabilityId: "CVE-2024-88888", status: ExceptionStatus.Proposed);
var revoked = CreateException("EXC-INACTIVE-002", vulnerabilityId: "CVE-2024-88888", status: ExceptionStatus.Revoked);
await _repository.CreateAsync(proposed, "actor");
await _repository.CreateAsync(revoked, "actor");
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-88888" };
// Act
var results = await _repository.GetActiveByScopeAsync(scope);
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task GetExpiringAsync_ShouldReturnExceptionsExpiringSoon()
{
// Arrange
var expiringSoon = CreateException("EXC-EXPIRING-001",
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(3));
var expiringLater = CreateException("EXC-EXPIRING-002",
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
await _repository.CreateAsync(expiringSoon, "actor");
await _repository.CreateAsync(expiringLater, "actor");
// Act
var results = await _repository.GetExpiringAsync(TimeSpan.FromDays(7));
// Assert
results.Should().ContainSingle();
results[0].ExceptionId.Should().Be("EXC-EXPIRING-001");
}
[Fact]
public async Task GetExpiredActiveAsync_ShouldReturnExpiredButActiveExceptions()
{
// Arrange - Create with past expiry
var expired = CreateException("EXC-EXPIRED-001",
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
await _repository.CreateAsync(expired, "actor");
// Act
var results = await _repository.GetExpiredActiveAsync();
// Assert
results.Should().ContainSingle();
results[0].ExceptionId.Should().Be("EXC-EXPIRED-001");
}
[Fact]
public async Task GetHistoryAsync_ShouldReturnEventsInOrder()
{
// Arrange
var exception = CreateException("EXC-HISTORY-001");
await _repository.CreateAsync(exception, "creator");
var updated = exception with
{
Version = 2,
Status = ExceptionStatus.Approved,
ApprovedAt = DateTimeOffset.UtcNow,
ApproverIds = ["approver@example.com"],
UpdatedAt = DateTimeOffset.UtcNow
};
await _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver");
var activated = updated with
{
Version = 3,
Status = ExceptionStatus.Active,
UpdatedAt = DateTimeOffset.UtcNow
};
await _repository.UpdateAsync(activated, ExceptionEventType.Activated, "activator");
// Act
var history = await _repository.GetHistoryAsync("EXC-HISTORY-001");
// Assert
history.EventCount.Should().Be(3);
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
history.Events[2].EventType.Should().Be(ExceptionEventType.Activated);
history.Events[0].SequenceNumber.Should().Be(1);
history.Events[1].SequenceNumber.Should().Be(2);
history.Events[2].SequenceNumber.Should().Be(3);
}
[Fact]
public async Task GetCountsAsync_ShouldReturnCorrectCounts()
{
// Arrange
await _repository.CreateAsync(CreateException("EXC-COUNT-001", status: ExceptionStatus.Proposed), "actor");
await _repository.CreateAsync(CreateException("EXC-COUNT-002", status: ExceptionStatus.Proposed), "actor");
await _repository.CreateAsync(CreateException("EXC-COUNT-003", status: ExceptionStatus.Active), "actor");
await _repository.CreateAsync(CreateException("EXC-COUNT-004", status: ExceptionStatus.Revoked), "actor");
await _repository.CreateAsync(CreateException("EXC-COUNT-005", status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(3)), "actor");
// Act
var counts = await _repository.GetCountsAsync();
// Assert
counts.Total.Should().Be(5);
counts.Proposed.Should().Be(2);
counts.Active.Should().Be(2);
counts.Revoked.Should().Be(1);
counts.ExpiringSoon.Should().BeGreaterOrEqualTo(1); // At least the one expiring in 3 days
}
[Fact]
public async Task CreateAsync_WithMetadata_ShouldPersistCorrectly()
{
// Arrange
var metadata = ImmutableDictionary<string, string>.Empty
.Add("team", "security")
.Add("priority", "high")
.Add("ticket", "SEC-123");
var exception = CreateException("EXC-META-001", metadata: metadata);
// Act
await _repository.CreateAsync(exception, "actor");
var fetched = await _repository.GetByIdAsync("EXC-META-001");
// Assert
fetched!.Metadata.Should().HaveCount(3);
fetched.Metadata["team"].Should().Be("security");
fetched.Metadata["priority"].Should().Be("high");
fetched.Metadata["ticket"].Should().Be("SEC-123");
}
[Fact]
public async Task CreateAsync_WithEvidenceRefs_ShouldPersistCorrectly()
{
// Arrange
var evidenceRefs = ImmutableArray.Create(
"sha256:evidence1",
"sha256:evidence2",
"https://evidence.example.com/doc1");
var exception = CreateException("EXC-EVIDENCE-001", evidenceRefs: evidenceRefs);
// Act
await _repository.CreateAsync(exception, "actor");
var fetched = await _repository.GetByIdAsync("EXC-EVIDENCE-001");
// Assert
fetched!.EvidenceRefs.Should().HaveCount(3);
fetched.EvidenceRefs.Should().Contain("sha256:evidence1");
fetched.EvidenceRefs.Should().Contain("https://evidence.example.com/doc1");
}
[Fact]
public async Task CreateAsync_WithCompensatingControls_ShouldPersistCorrectly()
{
// Arrange
var controls = ImmutableArray.Create(
"WAF blocking malicious patterns",
"Network segmentation prevents lateral movement");
var exception = CreateException("EXC-CONTROLS-001", compensatingControls: controls);
// Act
await _repository.CreateAsync(exception, "actor");
var fetched = await _repository.GetByIdAsync("EXC-CONTROLS-001");
// Assert
fetched!.CompensatingControls.Should().HaveCount(2);
fetched.CompensatingControls.Should().Contain("WAF blocking malicious patterns");
}
[Fact]
public async Task CreateAsync_WithEnvironments_ShouldPersistCorrectly()
{
// Arrange
var environments = ImmutableArray.Create("dev", "staging");
var exception = CreateException("EXC-ENV-001", environments: environments);
// Act
await _repository.CreateAsync(exception, "actor");
var fetched = await _repository.GetByIdAsync("EXC-ENV-001");
// Assert
fetched!.Scope.Environments.Should().HaveCount(2);
fetched.Scope.Environments.Should().Contain(["dev", "staging"]);
}
#region Test Helpers
private static ExceptionObject CreateException(
string exceptionId,
ExceptionStatus status = ExceptionStatus.Proposed,
ExceptionType type = ExceptionType.Vulnerability,
string vulnerabilityId = "CVE-2024-12345",
DateTimeOffset? expiresAt = null,
ImmutableDictionary<string, string>? metadata = null,
ImmutableArray<string>? evidenceRefs = null,
ImmutableArray<string>? compensatingControls = null,
ImmutableArray<string>? environments = null)
{
return new ExceptionObject
{
ExceptionId = exceptionId,
Version = 1,
Status = status,
Type = type,
Scope = new ExceptionScope
{
VulnerabilityId = vulnerabilityId,
Environments = environments ?? []
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt ?? DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = ExceptionReason.AcceptedRisk,
Rationale = "This is a test rationale that meets the minimum character requirement of 50 characters.",
EvidenceRefs = evidenceRefs ?? [],
CompensatingControls = compensatingControls ?? [],
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty
};
}
#endregion
}

View File

@@ -0,0 +1,509 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Storage.Postgres.Repositories;
using Xunit;
namespace StellaOps.Policy.Storage.Postgres.Tests;
/// <summary>
/// Integration tests for PostgresExceptionObjectRepository.
/// Tests the new auditable exception objects with event sourcing.
/// </summary>
[Collection(PolicyPostgresCollection.Name)]
public sealed class PostgresExceptionObjectRepositoryTests : IAsyncLifetime
{
private readonly PolicyPostgresFixture _fixture;
private readonly PostgresExceptionObjectRepository _repository;
private readonly Guid _tenantId = Guid.NewGuid();
public PostgresExceptionObjectRepositoryTests(PolicyPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new PolicyDataSource(Options.Create(options), NullLogger<PolicyDataSource>.Instance);
_repository = new PostgresExceptionObjectRepository(dataSource, NullLogger<PostgresExceptionObjectRepository>.Instance);
}
public Task InitializeAsync() => _fixture.TruncateAllTablesAsync();
public Task DisposeAsync() => Task.CompletedTask;
#region Create Tests
[Fact]
public async Task CreateAsync_WithValidException_PersistsException()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
// Act
var created = await _repository.CreateAsync(exception, "creator@example.com");
// Assert
created.Should().NotBeNull();
created.ExceptionId.Should().Be(exception.ExceptionId);
created.Version.Should().Be(1);
}
[Fact]
public async Task CreateAsync_RecordsCreatedEvent()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
// Act
await _repository.CreateAsync(exception, "creator@example.com");
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
// Assert
history.Events.Should().HaveCount(1);
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
history.Events[0].ActorId.Should().Be("creator@example.com");
history.Events[0].NewStatus.Should().Be(ExceptionStatus.Proposed);
}
[Fact]
public async Task CreateAsync_WithClientInfo_IncludesInEvent()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
// Act
await _repository.CreateAsync(exception, "creator@example.com", "192.168.1.1");
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
// Assert
history.Events[0].ClientInfo.Should().Be("192.168.1.1");
}
[Fact]
public async Task CreateAsync_WithWrongVersion_ThrowsException()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345") with { Version = 2 };
// Act & Assert
await Assert.ThrowsAsync<ArgumentException>(
() => _repository.CreateAsync(exception, "creator@example.com"));
}
#endregion
#region GetById Tests
[Fact]
public async Task GetByIdAsync_WithExistingException_ReturnsException()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
await _repository.CreateAsync(exception, "creator@example.com");
// Act
var fetched = await _repository.GetByIdAsync(exception.ExceptionId);
// Assert
fetched.Should().NotBeNull();
fetched!.ExceptionId.Should().Be(exception.ExceptionId);
fetched.Scope.VulnerabilityId.Should().Be("CVE-2024-12345");
fetched.Status.Should().Be(ExceptionStatus.Proposed);
}
[Fact]
public async Task GetByIdAsync_WithNonExistingException_ReturnsNull()
{
// Act
var fetched = await _repository.GetByIdAsync("EXC-NONEXISTENT");
// Assert
fetched.Should().BeNull();
}
#endregion
#region Update Tests
[Fact]
public async Task UpdateAsync_WithValidVersion_UpdatesException()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
await _repository.CreateAsync(exception, "creator@example.com");
var updated = exception with
{
Version = 2,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow,
ApprovedAt = DateTimeOffset.UtcNow,
ApproverIds = ["approver@example.com"]
};
// Act
var result = await _repository.UpdateAsync(
updated, ExceptionEventType.Approved, "approver@example.com", "Approved by security team");
// Assert
result.Version.Should().Be(2);
result.Status.Should().Be(ExceptionStatus.Approved);
}
[Fact]
public async Task UpdateAsync_RecordsEvent()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
await _repository.CreateAsync(exception, "creator@example.com");
var updated = exception with
{
Version = 2,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow
};
// Act
await _repository.UpdateAsync(
updated, ExceptionEventType.Approved, "approver@example.com", "Approved");
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
// Assert
history.Events.Should().HaveCount(2);
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
history.Events[1].PreviousStatus.Should().Be(ExceptionStatus.Proposed);
history.Events[1].NewStatus.Should().Be(ExceptionStatus.Approved);
}
[Fact]
public async Task UpdateAsync_WithWrongVersion_ThrowsConcurrencyException()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
await _repository.CreateAsync(exception, "creator@example.com");
// Try to update with wrong version
var updated = exception with
{
Version = 99, // Wrong version
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow
};
// Act & Assert
await Assert.ThrowsAsync<ConcurrencyException>(
() => _repository.UpdateAsync(updated, ExceptionEventType.Approved, "approver@example.com"));
}
#endregion
#region Query Tests
[Fact]
public async Task GetByFilterAsync_FiltersByStatus()
{
// Arrange
var proposed = CreateVulnerabilityException("CVE-2024-001");
var active = CreateVulnerabilityException("CVE-2024-002") with
{
ExceptionId = "EXC-ACTIVE",
Status = ExceptionStatus.Active
};
await _repository.CreateAsync(proposed, "creator");
await _repository.CreateAsync(active, "creator");
// Act
var results = await _repository.GetByFilterAsync(
new ExceptionFilter { Status = ExceptionStatus.Proposed });
// Assert
results.Should().HaveCount(1);
results[0].ExceptionId.Should().Be(proposed.ExceptionId);
}
[Fact]
public async Task GetByFilterAsync_FiltersByVulnerabilityId()
{
// Arrange
await _repository.CreateAsync(CreateVulnerabilityException("CVE-2024-001"), "creator");
await _repository.CreateAsync(CreateVulnerabilityException("CVE-2024-002") with
{
ExceptionId = "EXC-002"
}, "creator");
// Act
var results = await _repository.GetByFilterAsync(
new ExceptionFilter { VulnerabilityId = "CVE-2024-001" });
// Assert
results.Should().HaveCount(1);
results[0].Scope.VulnerabilityId.Should().Be("CVE-2024-001");
}
[Fact]
public async Task GetByFilterAsync_SupportsPagination()
{
// Arrange
for (int i = 0; i < 5; i++)
{
await _repository.CreateAsync(CreateVulnerabilityException($"CVE-2024-{i:000}") with
{
ExceptionId = $"EXC-{i:000}"
}, "creator");
}
// Act
var page1 = await _repository.GetByFilterAsync(new ExceptionFilter { Limit = 2, Offset = 0 });
var page2 = await _repository.GetByFilterAsync(new ExceptionFilter { Limit = 2, Offset = 2 });
// Assert
page1.Should().HaveCount(2);
page2.Should().HaveCount(2);
}
[Fact]
public async Task GetActiveByScopeAsync_FindsMatchingActiveExceptions()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345") with
{
Status = ExceptionStatus.Active
};
await _repository.CreateAsync(exception, "creator");
// Act
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" };
var results = await _repository.GetActiveByScopeAsync(scope);
// Assert
results.Should().HaveCount(1);
}
[Fact]
public async Task GetActiveByScopeAsync_ExcludesExpiredExceptions()
{
// Arrange
var expiredException = CreateVulnerabilityException("CVE-2024-12345") with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) // Already expired
};
await _repository.CreateAsync(expiredException, "creator");
// Act
var scope = new ExceptionScope { VulnerabilityId = "CVE-2024-12345" };
var results = await _repository.GetActiveByScopeAsync(scope);
// Assert
results.Should().BeEmpty();
}
[Fact]
public async Task GetExpiringAsync_FindsExceptionsWithinHorizon()
{
// Arrange
var expiringSoon = CreateVulnerabilityException("CVE-2024-001") with
{
ExceptionId = "EXC-EXPIRING",
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(3) // Within 7-day horizon
};
var expiresLater = CreateVulnerabilityException("CVE-2024-002") with
{
ExceptionId = "EXC-LATER",
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30) // Outside horizon
};
await _repository.CreateAsync(expiringSoon, "creator");
await _repository.CreateAsync(expiresLater, "creator");
// Act
var results = await _repository.GetExpiringAsync(TimeSpan.FromDays(7));
// Assert
results.Should().HaveCount(1);
results[0].ExceptionId.Should().Be("EXC-EXPIRING");
}
[Fact]
public async Task GetExpiredActiveAsync_FindsExpiredActiveExceptions()
{
// Arrange
var expiredActive = CreateVulnerabilityException("CVE-2024-001") with
{
ExceptionId = "EXC-EXPIRED-ACTIVE",
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
};
var validActive = CreateVulnerabilityException("CVE-2024-002") with
{
ExceptionId = "EXC-VALID-ACTIVE",
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
await _repository.CreateAsync(expiredActive, "creator");
await _repository.CreateAsync(validActive, "creator");
// Act
var results = await _repository.GetExpiredActiveAsync();
// Assert
results.Should().HaveCount(1);
results[0].ExceptionId.Should().Be("EXC-EXPIRED-ACTIVE");
}
#endregion
#region History and Counts Tests
[Fact]
public async Task GetHistoryAsync_ReturnsChronologicalEvents()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
await _repository.CreateAsync(exception, "creator");
// Approve
var approved = exception with
{
Version = 2,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow
};
await _repository.UpdateAsync(approved, ExceptionEventType.Approved, "approver");
// Activate
var activated = approved with
{
Version = 3,
Status = ExceptionStatus.Active,
UpdatedAt = DateTimeOffset.UtcNow
};
await _repository.UpdateAsync(activated, ExceptionEventType.Activated, "system");
// Act
var history = await _repository.GetHistoryAsync(exception.ExceptionId);
// Assert
history.Events.Should().HaveCount(3);
history.Events[0].EventType.Should().Be(ExceptionEventType.Created);
history.Events[1].EventType.Should().Be(ExceptionEventType.Approved);
history.Events[2].EventType.Should().Be(ExceptionEventType.Activated);
history.Events[0].SequenceNumber.Should().Be(1);
history.Events[1].SequenceNumber.Should().Be(2);
history.Events[2].SequenceNumber.Should().Be(3);
}
[Fact]
public async Task GetHistoryAsync_ForNonExistent_ReturnsEmptyHistory()
{
// Act
var history = await _repository.GetHistoryAsync("EXC-NONEXISTENT");
// Assert
history.ExceptionId.Should().Be("EXC-NONEXISTENT");
history.Events.Should().BeEmpty();
}
[Fact]
public async Task GetCountsAsync_ReturnsCorrectCounts()
{
// Arrange
await _repository.CreateAsync(CreateVulnerabilityException("CVE-001") with
{
ExceptionId = "EXC-1",
Status = ExceptionStatus.Proposed
}, "creator");
await _repository.CreateAsync(CreateVulnerabilityException("CVE-002") with
{
ExceptionId = "EXC-2",
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
}, "creator");
await _repository.CreateAsync(CreateVulnerabilityException("CVE-003") with
{
ExceptionId = "EXC-3",
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(3) // Expiring soon
}, "creator");
// Act
var counts = await _repository.GetCountsAsync();
// Assert
counts.Total.Should().Be(3);
counts.Proposed.Should().Be(1);
counts.Active.Should().Be(2);
counts.ExpiringSoon.Should().Be(1);
}
#endregion
#region Concurrent Update Tests
[Fact]
public async Task ConcurrentUpdates_FailsWithConcurrencyException()
{
// Arrange
var exception = CreateVulnerabilityException("CVE-2024-12345");
await _repository.CreateAsync(exception, "creator");
// Simulate concurrent updates by updating twice with same version
var update1 = exception with
{
Version = 2,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow
};
// First update succeeds
await _repository.UpdateAsync(update1, ExceptionEventType.Approved, "approver1");
// Second update with same expected version should fail
var update2 = exception with
{
Version = 2, // Still expecting version 1
Status = ExceptionStatus.Revoked,
UpdatedAt = DateTimeOffset.UtcNow
};
// Act & Assert
await Assert.ThrowsAsync<ConcurrencyException>(
() => _repository.UpdateAsync(update2, ExceptionEventType.Revoked, "approver2"));
}
#endregion
#region Test Helpers
private ExceptionObject CreateVulnerabilityException(string vulnerabilityId) => new()
{
ExceptionId = $"EXC-{Guid.NewGuid():N}"[..20],
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = vulnerabilityId,
TenantId = _tenantId
},
OwnerId = "security-team",
RequesterId = "developer@example.com",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(90),
ReasonCode = ExceptionReason.AcceptedRisk,
Rationale = "This vulnerability is accepted due to compensating controls in place that mitigate the risk."
};
#endregion
}

View File

@@ -27,6 +27,7 @@
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Storage.Postgres\StellaOps.Policy.Storage.Postgres.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Exceptions\StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\StellaOps.Policy.Scoring\StellaOps.Policy.Scoring.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,367 @@
using System.Collections.Immutable;
using Moq;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Exceptions.Services;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionEvaluator service.
/// </summary>
public sealed class ExceptionEvaluatorTests
{
private readonly Mock<IExceptionRepository> _repositoryMock;
private readonly ExceptionEvaluator _evaluator;
public ExceptionEvaluatorTests()
{
_repositoryMock = new Mock<IExceptionRepository>();
_evaluator = new ExceptionEvaluator(_repositoryMock.Object);
}
[Fact]
public async Task EvaluateAsync_WhenNoExceptions_ReturnsNoMatch()
{
SetupRepository([]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
Assert.Empty(result.MatchingExceptions);
}
[Fact]
public async Task EvaluateAsync_WhenMatchingActiveException_ReturnsMatch()
{
var exception = CreateActiveException();
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
Assert.Single(result.MatchingExceptions);
Assert.Equal(exception.ExceptionId, result.MatchingExceptions[0].ExceptionId);
}
[Fact]
public async Task EvaluateAsync_WhenExpiredException_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1) // Expired
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenProposedException_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Status = ExceptionStatus.Proposed
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenVulnerabilityIdDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException();
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-99999" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenArtifactDigestMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123"
}
};
SetupRepository([exception]);
var context = new FindingContext { ArtifactDigest = "sha256:abc123" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenArtifactDigestDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123"
}
};
SetupRepository([exception]);
var context = new FindingContext { ArtifactDigest = "sha256:different" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenPurlPatternMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
}
};
SetupRepository([exception]);
var context = new FindingContext { Purl = "pkg:npm/lodash@4.17.21" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenPurlPatternDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
}
};
SetupRepository([exception]);
var context = new FindingContext { Purl = "pkg:npm/axios@1.0.0" };
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenEnvironmentMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["staging", "dev"]
}
};
SetupRepository([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "dev"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenEnvironmentDoesNotMatch_ReturnsNoMatch()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["staging", "dev"]
}
};
SetupRepository([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "prod"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.False(result.HasException);
}
[Fact]
public async Task EvaluateAsync_WhenEmptyEnvironments_MatchesAnyEnvironment()
{
var exception = CreateActiveException() with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = [] // Empty means all environments
}
};
SetupRepository([exception]);
var context = new FindingContext
{
VulnerabilityId = "CVE-2024-12345",
Environment = "prod"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
[Fact]
public async Task EvaluateAsync_ReturnsEvidenceRefsFromMatchingExceptions()
{
var exception = CreateActiveException() with
{
EvidenceRefs = ["sha256:evidence1", "sha256:evidence2"]
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.Equal(2, result.AllEvidenceRefs.Count);
Assert.Contains("sha256:evidence1", result.AllEvidenceRefs);
}
[Fact]
public async Task EvaluateAsync_ReturnsPrimaryReasonFromMostSpecificMatch()
{
var exception = CreateActiveException() with
{
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This is a false positive because..."
};
SetupRepository([exception]);
var context = new FindingContext { VulnerabilityId = "CVE-2024-12345" };
var result = await _evaluator.EvaluateAsync(context);
Assert.Equal(ExceptionReason.FalsePositive, result.PrimaryReason);
Assert.Equal("This is a false positive because...", result.PrimaryRationale);
}
[Fact]
public async Task EvaluateAsync_MultipleMatches_SortsbySpecificity()
{
// More specific exception (has artifact digest)
var specificException = CreateActiveException("EXC-SPECIFIC") with
{
Scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123",
VulnerabilityId = "CVE-2024-12345"
}
};
// Less specific exception (only vuln ID)
var generalException = CreateActiveException("EXC-GENERAL") with
{
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
}
};
SetupRepository([generalException, specificException]);
var context = new FindingContext
{
ArtifactDigest = "sha256:abc123",
VulnerabilityId = "CVE-2024-12345"
};
var result = await _evaluator.EvaluateAsync(context);
Assert.Equal(2, result.MatchingExceptions.Count);
// Most specific should be first
Assert.Equal("EXC-SPECIFIC", result.MatchingExceptions[0].ExceptionId);
}
[Fact]
public async Task EvaluateBatchAsync_EvaluatesAllContexts()
{
var exception = CreateActiveException();
SetupRepository([exception]);
var contexts = new List<FindingContext>
{
new() { VulnerabilityId = "CVE-2024-12345" },
new() { VulnerabilityId = "CVE-2024-99999" },
new() { VulnerabilityId = "CVE-2024-12345" }
};
var results = await _evaluator.EvaluateBatchAsync(contexts);
Assert.Equal(3, results.Count);
Assert.True(results[0].HasException); // Matches
Assert.False(results[1].HasException); // No match
Assert.True(results[2].HasException); // Matches
}
[Fact]
public async Task EvaluateAsync_PolicyRuleMatches_ReturnsMatch()
{
var exception = CreateActiveException() with
{
Type = ExceptionType.Policy,
Scope = new ExceptionScope
{
PolicyRuleId = "NO-CRITICAL-VULNS"
}
};
SetupRepository([exception]);
var context = new FindingContext { PolicyRuleId = "NO-CRITICAL-VULNS" };
var result = await _evaluator.EvaluateAsync(context);
Assert.True(result.HasException);
}
private void SetupRepository(IReadOnlyList<ExceptionObject> exceptions)
{
_repositoryMock
.Setup(r => r.GetActiveByScopeAsync(It.IsAny<ExceptionScope>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(exceptions);
}
private static ExceptionObject CreateActiveException(string id = "EXC-TEST-001") => new()
{
ExceptionId = id,
Version = 1,
Status = ExceptionStatus.Active,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
CreatedAt = DateTimeOffset.UtcNow.AddDays(-7),
UpdatedAt = DateTimeOffset.UtcNow.AddDays(-7),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This vulnerability does not affect our deployment because we don't use the affected feature."
};
}

View File

@@ -0,0 +1,162 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionEvent model and factory methods.
/// </summary>
public sealed class ExceptionEventTests
{
private const string TestExceptionId = "EXC-TEST-001";
private const string TestActorId = "user@example.com";
[Fact]
public void ForCreated_CreatesValidCreatedEvent()
{
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId);
Assert.Equal(TestExceptionId, evt.ExceptionId);
Assert.Equal(1, evt.SequenceNumber);
Assert.Equal(ExceptionEventType.Created, evt.EventType);
Assert.Equal(TestActorId, evt.ActorId);
Assert.Null(evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Proposed, evt.NewStatus);
Assert.Equal(1, evt.NewVersion);
Assert.NotEqual(Guid.Empty, evt.EventId);
Assert.True(evt.OccurredAt <= DateTimeOffset.UtcNow);
}
[Fact]
public void ForCreated_WithDescription_IncludesDescription()
{
var description = "Custom creation description";
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId, description);
Assert.Equal(description, evt.Description);
}
[Fact]
public void ForCreated_WithClientInfo_IncludesClientInfo()
{
var clientInfo = "192.168.1.1";
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId, clientInfo: clientInfo);
Assert.Equal(clientInfo, evt.ClientInfo);
}
[Fact]
public void ForApproved_CreatesValidApprovedEvent()
{
var evt = ExceptionEvent.ForApproved(
TestExceptionId,
sequenceNumber: 2,
TestActorId,
newVersion: 2);
Assert.Equal(TestExceptionId, evt.ExceptionId);
Assert.Equal(2, evt.SequenceNumber);
Assert.Equal(ExceptionEventType.Approved, evt.EventType);
Assert.Equal(TestActorId, evt.ActorId);
Assert.Equal(ExceptionStatus.Proposed, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Approved, evt.NewStatus);
Assert.Equal(2, evt.NewVersion);
}
[Fact]
public void ForActivated_CreatesValidActivatedEvent()
{
var evt = ExceptionEvent.ForActivated(
TestExceptionId,
sequenceNumber: 3,
TestActorId,
newVersion: 3,
previousStatus: ExceptionStatus.Approved);
Assert.Equal(ExceptionEventType.Activated, evt.EventType);
Assert.Equal(ExceptionStatus.Approved, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Active, evt.NewStatus);
}
[Fact]
public void ForRevoked_CreatesValidRevokedEvent()
{
var reason = "No longer needed";
var evt = ExceptionEvent.ForRevoked(
TestExceptionId,
sequenceNumber: 4,
TestActorId,
newVersion: 4,
previousStatus: ExceptionStatus.Active,
reason: reason);
Assert.Equal(ExceptionEventType.Revoked, evt.EventType);
Assert.Equal(ExceptionStatus.Active, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Revoked, evt.NewStatus);
Assert.Contains(reason, evt.Description);
Assert.True(evt.Details.ContainsKey("reason"));
Assert.Equal(reason, evt.Details["reason"]);
}
[Fact]
public void ForExpired_CreatesValidExpiredEvent()
{
var evt = ExceptionEvent.ForExpired(
TestExceptionId,
sequenceNumber: 5,
newVersion: 5);
Assert.Equal(ExceptionEventType.Expired, evt.EventType);
Assert.Equal("system", evt.ActorId);
Assert.Equal(ExceptionStatus.Active, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Expired, evt.NewStatus);
}
[Fact]
public void ForExtended_CreatesValidExtendedEvent()
{
var previousExpiry = DateTimeOffset.UtcNow.AddDays(7);
var newExpiry = DateTimeOffset.UtcNow.AddDays(37);
var evt = ExceptionEvent.ForExtended(
TestExceptionId,
sequenceNumber: 6,
TestActorId,
newVersion: 6,
previousExpiry,
newExpiry);
Assert.Equal(ExceptionEventType.Extended, evt.EventType);
Assert.Equal(ExceptionStatus.Active, evt.PreviousStatus);
Assert.Equal(ExceptionStatus.Active, evt.NewStatus);
Assert.True(evt.Details.ContainsKey("previous_expiry"));
Assert.True(evt.Details.ContainsKey("new_expiry"));
}
[Theory]
[InlineData(ExceptionEventType.Created)]
[InlineData(ExceptionEventType.Updated)]
[InlineData(ExceptionEventType.Approved)]
[InlineData(ExceptionEventType.Activated)]
[InlineData(ExceptionEventType.Extended)]
[InlineData(ExceptionEventType.Revoked)]
[InlineData(ExceptionEventType.Expired)]
[InlineData(ExceptionEventType.EvidenceAttached)]
[InlineData(ExceptionEventType.CompensatingControlAdded)]
[InlineData(ExceptionEventType.Rejected)]
public void ExceptionEventType_HasAllExpectedValues(ExceptionEventType eventType)
{
// Verify all event types are defined
Assert.True(Enum.IsDefined(eventType));
}
[Fact]
public void ExceptionEvent_DetailsAreImmutable()
{
var evt = ExceptionEvent.ForCreated(TestExceptionId, TestActorId);
// Details should be an ImmutableDictionary
Assert.IsType<ImmutableDictionary<string, string>>(evt.Details);
}
}

View File

@@ -0,0 +1,153 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionHistory model.
/// </summary>
public sealed class ExceptionHistoryTests
{
private const string TestExceptionId = "EXC-TEST-001";
private const string TestActorId = "user@example.com";
[Fact]
public void ExceptionHistory_WithEvents_ReturnsCorrectCount()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
Assert.Equal(3, history.EventCount);
}
[Fact]
public void ExceptionHistory_Empty_ReturnsZeroCount()
{
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = []
};
Assert.Equal(0, history.EventCount);
}
[Fact]
public void FirstEventAt_WithEvents_ReturnsFirstEventTime()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
Assert.NotNull(history.FirstEventAt);
Assert.Equal(events[0].OccurredAt, history.FirstEventAt);
}
[Fact]
public void FirstEventAt_Empty_ReturnsNull()
{
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = []
};
Assert.Null(history.FirstEventAt);
}
[Fact]
public void LastEventAt_WithEvents_ReturnsLastEventTime()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
Assert.NotNull(history.LastEventAt);
Assert.Equal(events[^1].OccurredAt, history.LastEventAt);
}
[Fact]
public void LastEventAt_Empty_ReturnsNull()
{
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = []
};
Assert.Null(history.LastEventAt);
}
[Fact]
public void ExceptionHistory_PreservesEventOrder()
{
var events = CreateEventSequence();
var history = new ExceptionHistory
{
ExceptionId = TestExceptionId,
Events = events
};
// Events should be in chronological order by sequence number
for (int i = 0; i < history.Events.Length - 1; i++)
{
Assert.True(history.Events[i].SequenceNumber < history.Events[i + 1].SequenceNumber);
}
}
private static ImmutableArray<ExceptionEvent> CreateEventSequence()
{
var baseTime = DateTimeOffset.UtcNow.AddHours(-2);
return
[
new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = TestExceptionId,
SequenceNumber = 1,
EventType = ExceptionEventType.Created,
ActorId = TestActorId,
OccurredAt = baseTime,
PreviousStatus = null,
NewStatus = ExceptionStatus.Proposed,
NewVersion = 1
},
new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = TestExceptionId,
SequenceNumber = 2,
EventType = ExceptionEventType.Approved,
ActorId = "approver@example.com",
OccurredAt = baseTime.AddHours(1),
PreviousStatus = ExceptionStatus.Proposed,
NewStatus = ExceptionStatus.Approved,
NewVersion = 2
},
new ExceptionEvent
{
EventId = Guid.NewGuid(),
ExceptionId = TestExceptionId,
SequenceNumber = 3,
EventType = ExceptionEventType.Activated,
ActorId = "approver@example.com",
OccurredAt = baseTime.AddHours(2),
PreviousStatus = ExceptionStatus.Approved,
NewStatus = ExceptionStatus.Active,
NewVersion = 3
}
];
}
}

View File

@@ -0,0 +1,271 @@
using System.Collections.Immutable;
using StellaOps.Policy.Exceptions.Models;
using Xunit;
namespace StellaOps.Policy.Tests.Exceptions;
/// <summary>
/// Unit tests for ExceptionObject domain model.
/// </summary>
public sealed class ExceptionObjectTests
{
[Fact]
public void ExceptionObject_WithRequiredFields_IsValid()
{
var exception = CreateValidException();
Assert.Equal("EXC-TEST-001", exception.ExceptionId);
Assert.Equal(1, exception.Version);
Assert.Equal(ExceptionStatus.Proposed, exception.Status);
Assert.Equal(ExceptionType.Vulnerability, exception.Type);
Assert.Equal("owner@example.com", exception.OwnerId);
Assert.Equal("requester@example.com", exception.RequesterId);
}
[Fact]
public void ExceptionScope_WithVulnerabilityId_IsValid()
{
var scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithArtifactDigest_IsValid()
{
var scope = new ExceptionScope
{
ArtifactDigest = "sha256:abc123def456"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithPurlPattern_IsValid()
{
var scope = new ExceptionScope
{
PurlPattern = "pkg:npm/lodash@*"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithPolicyRuleId_IsValid()
{
var scope = new ExceptionScope
{
PolicyRuleId = "POLICY-NO-CRITICAL"
};
Assert.True(scope.IsValid);
}
[Fact]
public void ExceptionScope_Empty_IsNotValid()
{
var scope = new ExceptionScope();
Assert.False(scope.IsValid);
}
[Fact]
public void ExceptionScope_WithOnlyEnvironments_IsNotValid()
{
var scope = new ExceptionScope
{
Environments = ["prod", "staging"]
};
Assert.False(scope.IsValid);
}
[Fact]
public void IsEffective_WhenActiveAndNotExpired_ReturnsTrue()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.True(exception.IsEffective);
}
[Fact]
public void IsEffective_WhenActiveButExpired_ReturnsFalse()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
};
Assert.False(exception.IsEffective);
}
[Fact]
public void IsEffective_WhenProposed_ReturnsFalse()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Proposed,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.False(exception.IsEffective);
}
[Fact]
public void IsEffective_WhenRevoked_ReturnsFalse()
{
var exception = CreateValidException() with
{
Status = ExceptionStatus.Revoked,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.False(exception.IsEffective);
}
[Fact]
public void HasExpired_WhenPastExpiresAt_ReturnsTrue()
{
var exception = CreateValidException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
};
Assert.True(exception.HasExpired);
}
[Fact]
public void HasExpired_WhenBeforeExpiresAt_ReturnsFalse()
{
var exception = CreateValidException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
};
Assert.False(exception.HasExpired);
}
[Theory]
[InlineData(ExceptionReason.FalsePositive)]
[InlineData(ExceptionReason.AcceptedRisk)]
[InlineData(ExceptionReason.CompensatingControl)]
[InlineData(ExceptionReason.TestOnly)]
[InlineData(ExceptionReason.VendorNotAffected)]
[InlineData(ExceptionReason.ScheduledFix)]
[InlineData(ExceptionReason.DeprecationInProgress)]
[InlineData(ExceptionReason.RuntimeMitigation)]
[InlineData(ExceptionReason.NetworkIsolation)]
[InlineData(ExceptionReason.Other)]
public void ExceptionObject_SupportsAllReasonCodes(ExceptionReason reason)
{
var exception = CreateValidException() with
{
ReasonCode = reason
};
Assert.Equal(reason, exception.ReasonCode);
}
[Theory]
[InlineData(ExceptionType.Vulnerability)]
[InlineData(ExceptionType.Policy)]
[InlineData(ExceptionType.Unknown)]
[InlineData(ExceptionType.Component)]
public void ExceptionObject_SupportsAllExceptionTypes(ExceptionType type)
{
var exception = CreateValidException() with
{
Type = type
};
Assert.Equal(type, exception.Type);
}
[Fact]
public void ExceptionObject_WithEvidenceRefs_PreservesRefs()
{
var refs = ImmutableArray.Create("sha256:evidence1", "sha256:evidence2");
var exception = CreateValidException() with
{
EvidenceRefs = refs
};
Assert.Equal(2, exception.EvidenceRefs.Length);
Assert.Contains("sha256:evidence1", exception.EvidenceRefs);
Assert.Contains("sha256:evidence2", exception.EvidenceRefs);
}
[Fact]
public void ExceptionObject_WithCompensatingControls_PreservesControls()
{
var controls = ImmutableArray.Create("WAF protection", "Rate limiting");
var exception = CreateValidException() with
{
CompensatingControls = controls
};
Assert.Equal(2, exception.CompensatingControls.Length);
Assert.Contains("WAF protection", exception.CompensatingControls);
}
[Fact]
public void ExceptionObject_WithMetadata_PreservesMetadata()
{
var metadata = ImmutableDictionary<string, string>.Empty
.Add("jira_ticket", "SEC-1234")
.Add("risk_owner", "security-team");
var exception = CreateValidException() with
{
Metadata = metadata
};
Assert.Equal(2, exception.Metadata.Count);
Assert.Equal("SEC-1234", exception.Metadata["jira_ticket"]);
}
[Fact]
public void ExceptionObject_WithApprovers_PreservesApproverIds()
{
var approvers = ImmutableArray.Create("approver1@example.com", "approver2@example.com");
var exception = CreateValidException() with
{
ApproverIds = approvers,
ApprovedAt = DateTimeOffset.UtcNow,
Status = ExceptionStatus.Approved
};
Assert.Equal(2, exception.ApproverIds.Length);
Assert.Contains("approver1@example.com", exception.ApproverIds);
}
private static ExceptionObject CreateValidException() => new()
{
ExceptionId = "EXC-TEST-001",
Version = 1,
Status = ExceptionStatus.Proposed,
Type = ExceptionType.Vulnerability,
Scope = new ExceptionScope
{
VulnerabilityId = "CVE-2024-12345",
Environments = ["prod"]
},
OwnerId = "owner@example.com",
RequesterId = "requester@example.com",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(90),
ReasonCode = ExceptionReason.FalsePositive,
Rationale = "This vulnerability does not affect our deployment because we don't use the affected feature."
};
}

View File

@@ -21,6 +21,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
</ItemGroup>
</Project>