Add unit tests for ExceptionEvaluator, ExceptionEvent, ExceptionHistory, and ExceptionObject models

- Implemented comprehensive unit tests for the ExceptionEvaluator service, covering various scenarios including matching exceptions, environment checks, and evidence references.
- Created tests for the ExceptionEvent model to validate event creation methods and ensure correct event properties.
- Developed tests for the ExceptionHistory model to verify event count, order, and timestamps.
- Added tests for the ExceptionObject domain model to ensure validity checks and property preservation for various fields.
This commit is contained in:
StellaOps Bot
2025-12-21 00:34:35 +02:00
parent 6928124d33
commit b7b27c8740
32 changed files with 8687 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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