Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Gateway/Endpoints/ExceptionEndpoints.cs
2026-01-05 09:35:33 +02:00

560 lines
22 KiB
C#

// <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,
[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<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,
[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<IResult>(
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<IResult>(
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<IResult>(
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<IResult>(
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<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
}