feat(rate-limiting): Implement core rate limiting functionality with configuration, decision-making, metrics, middleware, and service registration

- Add RateLimitConfig for configuration management with YAML binding support.
- Introduce RateLimitDecision to encapsulate the result of rate limit checks.
- Implement RateLimitMetrics for OpenTelemetry metrics tracking.
- Create RateLimitMiddleware for enforcing rate limits on incoming requests.
- Develop RateLimitService to orchestrate instance and environment rate limit checks.
- Add RateLimitServiceCollectionExtensions for dependency injection registration.
This commit is contained in:
master
2025-12-17 18:02:37 +02:00
parent 394b57f6bf
commit 8bbfe4d2d2
211 changed files with 47179 additions and 1590 deletions

View File

@@ -0,0 +1,321 @@
// -----------------------------------------------------------------------------
// UnknownsEndpoints.cs
// Sprint: SPRINT_3600_0002_0001_unknowns_ranking_containment
// Task: UNK-RANK-007, UNK-RANK-008 - Implement GET /unknowns API with sorting/pagination
// Description: REST API for querying and filtering unknowns
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Unknowns.Core.Models;
using StellaOps.Unknowns.Core.Repositories;
using StellaOps.Unknowns.Core.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class UnknownsEndpoints
{
public static void MapUnknownsEndpoints(this RouteGroupBuilder apiGroup)
{
var unknowns = apiGroup.MapGroup("/unknowns");
unknowns.MapGet("/", HandleListAsync)
.WithName("scanner.unknowns.list")
.Produces<UnknownsListResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.WithDescription("List unknowns with optional sorting and filtering");
unknowns.MapGet("/{id}", HandleGetByIdAsync)
.WithName("scanner.unknowns.get")
.Produces<UnknownDetailResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.WithDescription("Get details of a specific unknown");
unknowns.MapGet("/{id}/proof", HandleGetProofAsync)
.WithName("scanner.unknowns.proof")
.Produces<UnknownProofResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.WithDescription("Get the proof trail for an unknown ranking");
}
/// <summary>
/// GET /unknowns?sort=score&amp;order=desc&amp;artifact=sha256:...&amp;reason=missing_vex&amp;page=1&amp;limit=50
/// </summary>
private static async Task<IResult> HandleListAsync(
[FromQuery] string? sort,
[FromQuery] string? order,
[FromQuery] string? artifact,
[FromQuery] string? reason,
[FromQuery] string? kind,
[FromQuery] string? severity,
[FromQuery] double? minScore,
[FromQuery] double? maxScore,
[FromQuery] int? page,
[FromQuery] int? limit,
IUnknownRepository repository,
IUnknownRanker ranker,
CancellationToken cancellationToken)
{
// Validate and default pagination
var pageNum = Math.Max(1, page ?? 1);
var pageSize = Math.Clamp(limit ?? 50, 1, 200);
// Parse sort field
var sortField = (sort?.ToLowerInvariant()) switch
{
"score" => UnknownSortField.Score,
"created" => UnknownSortField.Created,
"updated" => UnknownSortField.Updated,
"severity" => UnknownSortField.Severity,
"popularity" => UnknownSortField.Popularity,
_ => UnknownSortField.Score // Default to score
};
var sortOrder = (order?.ToLowerInvariant()) switch
{
"asc" => SortOrder.Ascending,
_ => SortOrder.Descending // Default to descending (highest first)
};
// Parse filters
UnknownKind? kindFilter = kind != null && Enum.TryParse<UnknownKind>(kind, true, out var k) ? k : null;
UnknownSeverity? severityFilter = severity != null && Enum.TryParse<UnknownSeverity>(severity, true, out var s) ? s : null;
var query = new UnknownListQuery(
ArtifactDigest: artifact,
Reason: reason,
Kind: kindFilter,
Severity: severityFilter,
MinScore: minScore,
MaxScore: maxScore,
SortField: sortField,
SortOrder: sortOrder,
Page: pageNum,
PageSize: pageSize);
var result = await repository.ListUnknownsAsync(query, cancellationToken);
return Results.Ok(new UnknownsListResponse(
Items: result.Items.Select(UnknownItemResponse.FromUnknownItem).ToList(),
TotalCount: result.TotalCount,
Page: pageNum,
PageSize: pageSize,
TotalPages: (int)Math.Ceiling((double)result.TotalCount / pageSize),
HasNextPage: pageNum * pageSize < result.TotalCount,
HasPreviousPage: pageNum > 1));
}
/// <summary>
/// GET /unknowns/{id}
/// </summary>
private static async Task<IResult> HandleGetByIdAsync(
Guid id,
IUnknownRepository repository,
CancellationToken cancellationToken)
{
var unknown = await repository.GetByIdAsync(id, cancellationToken);
if (unknown is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Unknown not found",
Detail = $"No unknown found with ID: {id}",
Status = StatusCodes.Status404NotFound
});
}
return Results.Ok(UnknownDetailResponse.FromUnknown(unknown));
}
/// <summary>
/// GET /unknowns/{id}/proof
/// </summary>
private static async Task<IResult> HandleGetProofAsync(
Guid id,
IUnknownRepository repository,
CancellationToken cancellationToken)
{
var unknown = await repository.GetByIdAsync(id, cancellationToken);
if (unknown is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Unknown not found",
Detail = $"No unknown found with ID: {id}",
Status = StatusCodes.Status404NotFound
});
}
var proofRef = unknown.ProofRef;
if (string.IsNullOrEmpty(proofRef))
{
return Results.NotFound(new ProblemDetails
{
Title = "Proof not available",
Detail = $"No proof trail available for unknown: {id}",
Status = StatusCodes.Status404NotFound
});
}
// In a real implementation, read proof from storage
return Results.Ok(new UnknownProofResponse(
UnknownId: id,
ProofRef: proofRef,
CreatedAt: unknown.SysFrom));
}
}
/// <summary>
/// Response model for unknowns list.
/// </summary>
public sealed record UnknownsListResponse(
IReadOnlyList<UnknownItemResponse> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages,
bool HasNextPage,
bool HasPreviousPage);
/// <summary>
/// Compact unknown item for list response.
/// </summary>
public sealed record UnknownItemResponse(
Guid Id,
string SubjectRef,
string Kind,
string? Severity,
double Score,
string TriageBand,
string Priority,
BlastRadiusResponse? BlastRadius,
ContainmentResponse? Containment,
DateTimeOffset CreatedAt)
{
public static UnknownItemResponse FromUnknownItem(UnknownItem item) => new(
Id: Guid.TryParse(item.Id, out var id) ? id : Guid.Empty,
SubjectRef: item.ArtifactPurl ?? item.ArtifactDigest,
Kind: string.Join(",", item.Reasons),
Severity: null, // Would come from full Unknown
Score: item.Score,
TriageBand: item.Score.ToTriageBand().ToString(),
Priority: item.Score.ToPriorityLabel(),
BlastRadius: item.BlastRadius != null
? new BlastRadiusResponse(item.BlastRadius.Dependents, item.BlastRadius.NetFacing, item.BlastRadius.Privilege)
: null,
Containment: item.Containment != null
? new ContainmentResponse(item.Containment.Seccomp, item.Containment.Fs)
: null,
CreatedAt: DateTimeOffset.UtcNow); // Would come from Unknown.SysFrom
}
/// <summary>
/// Blast radius in API response.
/// </summary>
public sealed record BlastRadiusResponse(int Dependents, bool NetFacing, string Privilege);
/// <summary>
/// Containment signals in API response.
/// </summary>
public sealed record ContainmentResponse(string Seccomp, string Fs);
/// <summary>
/// Detailed unknown response.
/// </summary>
public sealed record UnknownDetailResponse(
Guid Id,
string TenantId,
string SubjectHash,
string SubjectType,
string SubjectRef,
string Kind,
string? Severity,
double Score,
string TriageBand,
double PopularityScore,
int DeploymentCount,
double UncertaintyScore,
BlastRadiusResponse? BlastRadius,
ContainmentResponse? Containment,
string? ProofRef,
DateTimeOffset ValidFrom,
DateTimeOffset? ValidTo,
DateTimeOffset SysFrom,
DateTimeOffset? ResolvedAt,
string? ResolutionType,
string? ResolutionRef)
{
public static UnknownDetailResponse FromUnknown(Unknown u) => new(
Id: u.Id,
TenantId: u.TenantId,
SubjectHash: u.SubjectHash,
SubjectType: u.SubjectType.ToString(),
SubjectRef: u.SubjectRef,
Kind: u.Kind.ToString(),
Severity: u.Severity?.ToString(),
Score: u.TriageScore,
TriageBand: u.TriageScore.ToTriageBand().ToString(),
PopularityScore: u.PopularityScore,
DeploymentCount: u.DeploymentCount,
UncertaintyScore: u.UncertaintyScore,
BlastRadius: u.BlastDependents.HasValue
? new BlastRadiusResponse(u.BlastDependents.Value, u.BlastNetFacing ?? false, u.BlastPrivilege ?? "user")
: null,
Containment: !string.IsNullOrEmpty(u.ContainmentSeccomp) || !string.IsNullOrEmpty(u.ContainmentFs)
? new ContainmentResponse(u.ContainmentSeccomp ?? "unknown", u.ContainmentFs ?? "unknown")
: null,
ProofRef: u.ProofRef,
ValidFrom: u.ValidFrom,
ValidTo: u.ValidTo,
SysFrom: u.SysFrom,
ResolvedAt: u.ResolvedAt,
ResolutionType: u.ResolutionType?.ToString(),
ResolutionRef: u.ResolutionRef);
}
/// <summary>
/// Proof trail response.
/// </summary>
public sealed record UnknownProofResponse(
Guid UnknownId,
string ProofRef,
DateTimeOffset CreatedAt);
/// <summary>
/// Sort fields for unknowns query.
/// </summary>
public enum UnknownSortField
{
Score,
Created,
Updated,
Severity,
Popularity
}
/// <summary>
/// Sort order.
/// </summary>
public enum SortOrder
{
Ascending,
Descending
}
/// <summary>
/// Query parameters for listing unknowns.
/// </summary>
public sealed record UnknownListQuery(
string? ArtifactDigest,
string? Reason,
UnknownKind? Kind,
UnknownSeverity? Severity,
double? MinScore,
double? MaxScore,
UnknownSortField SortField,
SortOrder SortOrder,
int Page,
int PageSize);