// // Copyright (c) StellaOps. All rights reserved. // Licensed under the AGPL-3.0-or-later license. // 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; /// /// Exception API endpoints for Policy Gateway. /// public static class ExceptionEndpoints { /// /// Maps exception endpoints to the application. /// 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( [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( 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( 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( 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( CreateExceptionRequest request, HttpContext context, IExceptionRepository repository, [FromServices] TimeProvider timeProvider, 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 <= timeProvider.GetUtcNow()) { 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 > timeProvider.GetUtcNow().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 = timeProvider.GetUtcNow(), UpdatedAt = timeProvider.GetUtcNow(), 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.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( string id, UpdateExceptionRequest request, HttpContext context, IExceptionRepository repository, [FromServices] TimeProvider timeProvider, 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 = timeProvider.GetUtcNow(), 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( string id, ApproveExceptionRequest? request, HttpContext context, IExceptionRepository repository, [FromServices] TimeProvider timeProvider, 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 = timeProvider.GetUtcNow(), ApprovedAt = timeProvider.GetUtcNow(), 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( string id, HttpContext context, IExceptionRepository repository, [FromServices] TimeProvider timeProvider, 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 = timeProvider.GetUtcNow() }; 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( string id, ExtendExceptionRequest request, HttpContext context, IExceptionRepository repository, [FromServices] TimeProvider timeProvider, 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 = timeProvider.GetUtcNow(), 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( string id, [FromBody] RevokeExceptionRequest? request, HttpContext context, IExceptionRepository repository, [FromServices] TimeProvider timeProvider, 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 = timeProvider.GetUtcNow() }; 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( [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 }