Add unit tests for ExceptionEvaluator, ExceptionEvent, ExceptionHistory, and ExceptionObject models
- Implemented comprehensive unit tests for the ExceptionEvaluator service, covering various scenarios including matching exceptions, environment checks, and evidence references. - Created tests for the ExceptionEvent model to validate event creation methods and ensure correct event properties. - Developed tests for the ExceptionHistory model to verify event count, order, and timestamps. - Added tests for the ExceptionObject domain model to ensure validity checks and property preservation for various fields.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
|
||||
Reference in New Issue
Block a user