560 lines
22 KiB
C#
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
|
|
}
|