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