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:
@@ -0,0 +1,438 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KeyRotationEndpoints.cs
|
||||
// Sprint: SPRINT_0501_0008_0001_proof_chain_key_rotation
|
||||
// Task: PROOF-KEY-0010 - Implement key rotation API endpoints
|
||||
// Description: API endpoints for key rotation and trust anchor management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Signer.KeyManagement;
|
||||
|
||||
namespace StellaOps.Signer.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for key rotation operations.
|
||||
/// Implements advisory §8.2 key rotation workflow.
|
||||
/// </summary>
|
||||
public static class KeyRotationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Map key rotation endpoints to the router.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapKeyRotationEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/anchors")
|
||||
.WithTags("KeyRotation", "TrustAnchors")
|
||||
.RequireAuthorization("KeyManagement");
|
||||
|
||||
// Key management endpoints
|
||||
group.MapPost("/{anchorId:guid}/keys", AddKeyAsync)
|
||||
.WithName("AddKey")
|
||||
.WithSummary("Add a new signing key to a trust anchor")
|
||||
.Produces<AddKeyResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{anchorId:guid}/keys/{keyId}/revoke", RevokeKeyAsync)
|
||||
.WithName("RevokeKey")
|
||||
.WithSummary("Revoke a signing key from a trust anchor")
|
||||
.Produces<RevokeKeyResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{anchorId:guid}/keys/{keyId}/validity", CheckKeyValidityAsync)
|
||||
.WithName("CheckKeyValidity")
|
||||
.WithSummary("Check if a key was valid at a specific time")
|
||||
.Produces<KeyValidityResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{anchorId:guid}/keys/history", GetKeyHistoryAsync)
|
||||
.WithName("GetKeyHistory")
|
||||
.WithSummary("Get the full key history for a trust anchor")
|
||||
.Produces<KeyHistoryResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{anchorId:guid}/keys/warnings", GetRotationWarningsAsync)
|
||||
.WithName("GetRotationWarnings")
|
||||
.WithSummary("Get rotation warnings for a trust anchor")
|
||||
.Produces<RotationWarningsResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new signing key to a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> AddKeyAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromBody] AddKeyRequestDto request,
|
||||
IKeyRotationService rotationService,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.AddKey");
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Request body is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var addRequest = new AddKeyRequest
|
||||
{
|
||||
KeyId = request.KeyId,
|
||||
PublicKey = request.PublicKey,
|
||||
Algorithm = request.Algorithm,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
Metadata = request.Metadata
|
||||
};
|
||||
|
||||
var result = await rotationService.AddKeyAsync(anchorId, addRequest, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key addition failed",
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Added key {KeyId} to anchor {AnchorId}, audit log {AuditLogId}",
|
||||
request.KeyId, anchorId, result.AuditLogId);
|
||||
|
||||
var response = new AddKeyResponseDto
|
||||
{
|
||||
KeyId = request.KeyId,
|
||||
AnchorId = anchorId,
|
||||
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
||||
AuditLogId = result.AuditLogId
|
||||
};
|
||||
|
||||
return Results.Created($"/api/v1/anchors/{anchorId}/keys/{request.KeyId}", response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Anchor not found",
|
||||
detail: $"Trust anchor {anchorId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to add key {KeyId} to anchor {AnchorId}", request.KeyId, anchorId);
|
||||
return Results.Problem(
|
||||
title: "Internal error",
|
||||
detail: "An unexpected error occurred.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revoke a signing key from a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> RevokeKeyAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromRoute] string keyId,
|
||||
[FromBody] RevokeKeyRequestDto request,
|
||||
IKeyRotationService rotationService,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("KeyRotationEndpoints.RevokeKey");
|
||||
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Invalid request",
|
||||
detail: "Revocation reason is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var revokeRequest = new RevokeKeyRequest
|
||||
{
|
||||
Reason = request.Reason,
|
||||
EffectiveAt = request.EffectiveAt
|
||||
};
|
||||
|
||||
var result = await rotationService.RevokeKeyAsync(anchorId, keyId, revokeRequest, ct);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key revocation failed",
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Revoked key {KeyId} from anchor {AnchorId}, reason: {Reason}, audit log {AuditLogId}",
|
||||
keyId, anchorId, request.Reason, result.AuditLogId);
|
||||
|
||||
var response = new RevokeKeyResponseDto
|
||||
{
|
||||
KeyId = keyId,
|
||||
AnchorId = anchorId,
|
||||
RevokedAt = request.EffectiveAt ?? DateTimeOffset.UtcNow,
|
||||
Reason = request.Reason,
|
||||
AllowedKeyIds = result.AllowedKeyIds.ToList(),
|
||||
RevokedKeyIds = result.RevokedKeyIds.ToList(),
|
||||
AuditLogId = result.AuditLogId
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key or anchor not found",
|
||||
detail: $"Trust anchor {anchorId} or key {keyId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to revoke key {KeyId} from anchor {AnchorId}", keyId, anchorId);
|
||||
return Results.Problem(
|
||||
title: "Internal error",
|
||||
detail: "An unexpected error occurred.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a key was valid at a specific time.
|
||||
/// </summary>
|
||||
private static async Task<IResult> CheckKeyValidityAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
[FromRoute] string keyId,
|
||||
[FromQuery] DateTimeOffset? signedAt,
|
||||
IKeyRotationService rotationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var checkTime = signedAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await rotationService.CheckKeyValidityAsync(anchorId, keyId, checkTime, ct);
|
||||
|
||||
var response = new KeyValidityResponseDto
|
||||
{
|
||||
KeyId = keyId,
|
||||
AnchorId = anchorId,
|
||||
CheckedAt = checkTime,
|
||||
IsValid = result.IsValid,
|
||||
Status = result.Status.ToString(),
|
||||
AddedAt = result.AddedAt,
|
||||
RevokedAt = result.RevokedAt,
|
||||
InvalidReason = result.InvalidReason
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Key or anchor not found",
|
||||
detail: $"Trust anchor {anchorId} or key {keyId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the full key history for a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetKeyHistoryAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
IKeyRotationService rotationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var history = await rotationService.GetKeyHistoryAsync(anchorId, ct);
|
||||
|
||||
var response = new KeyHistoryResponseDto
|
||||
{
|
||||
AnchorId = anchorId,
|
||||
Entries = history.Select(e => new KeyHistoryEntryDto
|
||||
{
|
||||
KeyId = e.KeyId,
|
||||
Algorithm = e.Algorithm,
|
||||
AddedAt = e.AddedAt,
|
||||
RevokedAt = e.RevokedAt,
|
||||
RevokeReason = e.RevokeReason,
|
||||
ExpiresAt = e.ExpiresAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Anchor not found",
|
||||
detail: $"Trust anchor {anchorId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get rotation warnings for a trust anchor.
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetRotationWarningsAsync(
|
||||
[FromRoute] Guid anchorId,
|
||||
IKeyRotationService rotationService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var warnings = await rotationService.GetRotationWarningsAsync(anchorId, ct);
|
||||
|
||||
var response = new RotationWarningsResponseDto
|
||||
{
|
||||
AnchorId = anchorId,
|
||||
Warnings = warnings.Select(w => new RotationWarningDto
|
||||
{
|
||||
KeyId = w.KeyId,
|
||||
WarningType = w.WarningType.ToString(),
|
||||
Message = w.Message,
|
||||
CriticalAt = w.CriticalAt
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.Problem(
|
||||
title: "Anchor not found",
|
||||
detail: $"Trust anchor {anchorId} not found.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for adding a key.
|
||||
/// </summary>
|
||||
public sealed record AddKeyRequestDto
|
||||
{
|
||||
[Required]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string PublicKey { get; init; }
|
||||
|
||||
[Required]
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for adding a key.
|
||||
/// </summary>
|
||||
public sealed record AddKeyResponseDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required List<string> AllowedKeyIds { get; init; }
|
||||
public Guid? AuditLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for revoking a key.
|
||||
/// </summary>
|
||||
public sealed record RevokeKeyRequestDto
|
||||
{
|
||||
[Required]
|
||||
public required string Reason { get; init; }
|
||||
|
||||
public DateTimeOffset? EffectiveAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for revoking a key.
|
||||
/// </summary>
|
||||
public sealed record RevokeKeyResponseDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required DateTimeOffset RevokedAt { get; init; }
|
||||
public required string Reason { get; init; }
|
||||
public required List<string> AllowedKeyIds { get; init; }
|
||||
public required List<string> RevokedKeyIds { get; init; }
|
||||
public Guid? AuditLogId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for key validity check.
|
||||
/// </summary>
|
||||
public sealed record KeyValidityResponseDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required DateTimeOffset CheckedAt { get; init; }
|
||||
public required bool IsValid { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? InvalidReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for key history.
|
||||
/// </summary>
|
||||
public sealed record KeyHistoryResponseDto
|
||||
{
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required List<KeyHistoryEntryDto> Entries { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a key history entry.
|
||||
/// </summary>
|
||||
public sealed record KeyHistoryEntryDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Algorithm { get; init; }
|
||||
public required DateTimeOffset AddedAt { get; init; }
|
||||
public DateTimeOffset? RevokedAt { get; init; }
|
||||
public string? RevokeReason { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response DTO for rotation warnings.
|
||||
/// </summary>
|
||||
public sealed record RotationWarningsResponseDto
|
||||
{
|
||||
public required Guid AnchorId { get; init; }
|
||||
public required List<RotationWarningDto> Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO for a rotation warning.
|
||||
/// </summary>
|
||||
public sealed record RotationWarningDto
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string WarningType { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public DateTimeOffset? CriticalAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user