partly or unimplemented features - now implemented
This commit is contained in:
@@ -19,6 +19,7 @@ using StellaOps.Attestor.Core.Storage;
|
||||
using StellaOps.Attestor.Core.Submission;
|
||||
using StellaOps.Attestor.Core.Verification;
|
||||
using StellaOps.Attestor.Infrastructure;
|
||||
using StellaOps.Attestor.ProofChain;
|
||||
using StellaOps.Attestor.Spdx3;
|
||||
using StellaOps.Attestor.Watchlist;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
@@ -138,6 +139,7 @@ internal static class AttestorWebServiceComposition
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddAttestorInfrastructure();
|
||||
builder.Services.AddProofChainServices();
|
||||
|
||||
builder.Services.AddScoped<Services.IProofChainQueryService, Services.ProofChainQueryService>();
|
||||
builder.Services.AddScoped<Services.IProofVerificationService, Services.ProofVerificationService>();
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionContracts.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: API contracts for DSSE-signed exception operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to sign and create a DSSE-signed exception.
|
||||
/// </summary>
|
||||
public sealed record SignExceptionRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The exception entry to sign.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exception")]
|
||||
public required ExceptionEntryDto Exception { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject (artifact) this exception applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required SubjectDto Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The recheck policy for this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckPolicy")]
|
||||
public required RecheckPolicyDto RecheckPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The environments this exception applies to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("environments")]
|
||||
public IReadOnlyList<string>? Environments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of violations this exception covers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredViolationIds")]
|
||||
public IReadOnlyList<string>? CoveredViolationIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception entry data transfer object.
|
||||
/// </summary>
|
||||
public sealed record ExceptionEntryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Exception identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionId")]
|
||||
public required string ExceptionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason codes covered by this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredReasons")]
|
||||
public IReadOnlyList<string>? CoveredReasons { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tiers covered by this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("coveredTiers")]
|
||||
public IReadOnlyList<string>? CoveredTiers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this exception expires (ISO 8601 format).
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiresAt")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification for the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who approved this exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvedBy")]
|
||||
public string? ApprovedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject data transfer object for API requests.
|
||||
/// </summary>
|
||||
public sealed record SubjectDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The name or identifier of the subject.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Digests of the subject in algorithm:hex format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required IReadOnlyDictionary<string, string> Digest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recheck policy configuration for exceptions.
|
||||
/// </summary>
|
||||
public sealed record RecheckPolicyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Interval in days between automated rechecks. Default: 30.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckIntervalDays")]
|
||||
public int RecheckIntervalDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether automatic recheck scheduling is enabled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("autoRecheckEnabled")]
|
||||
public bool AutoRecheckEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum renewal count before escalation required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxRenewalCount")]
|
||||
public int? MaxRenewalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether re-approval is required on expiry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresReapprovalOnExpiry")]
|
||||
public bool RequiresReapprovalOnExpiry { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Roles required for approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("approvalRoles")]
|
||||
public IReadOnlyList<string>? ApprovalRoles { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response after signing an exception.
|
||||
/// </summary>
|
||||
public sealed record SignedExceptionResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The content-addressed ID of the signed exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionContentId")]
|
||||
public required string ExceptionContentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The DSSE envelope containing the signed statement.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the exception was signed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signedAt")]
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The initial status of the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the next recheck is scheduled.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nextRecheckAt")]
|
||||
public DateTimeOffset? NextRecheckAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope data transfer object.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelopeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The payload type.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payloadType")]
|
||||
public required string PayloadType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public required string Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Signatures over the payload.
|
||||
/// </summary>
|
||||
[JsonPropertyName("signatures")]
|
||||
public required IReadOnlyList<DsseSignatureDto> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature data transfer object.
|
||||
/// </summary>
|
||||
public sealed record DsseSignatureDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The key ID that produced this signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyid")]
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded signature.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sig")]
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify a signed exception.
|
||||
/// </summary>
|
||||
public sealed record VerifyExceptionRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope to verify.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Allowed key IDs for verification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("allowedKeyIds")]
|
||||
public IReadOnlyList<string>? AllowedKeyIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from exception verification.
|
||||
/// </summary>
|
||||
public sealed record VerifyExceptionResponseDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the signature is valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isValid")]
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The key ID that signed the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("keyId")]
|
||||
public string? KeyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The exception content ID if valid.
|
||||
/// </summary>
|
||||
[JsonPropertyName("exceptionContentId")]
|
||||
public string? ExceptionContentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if verification failed.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recheck status of the exception.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckStatus")]
|
||||
public RecheckStatusDto? RecheckStatus { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recheck status for an exception.
|
||||
/// </summary>
|
||||
public sealed record RecheckStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a recheck is required.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recheckRequired")]
|
||||
public required bool RecheckRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception has expired.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isExpired")]
|
||||
public required bool IsExpired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the exception is expiring soon.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expiringWithinWarningWindow")]
|
||||
public required bool ExpiringWithinWarningWindow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days until expiry.
|
||||
/// </summary>
|
||||
[JsonPropertyName("daysUntilExpiry")]
|
||||
public int? DaysUntilExpiry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Next recheck due date.
|
||||
/// </summary>
|
||||
[JsonPropertyName("nextRecheckDue")]
|
||||
public DateTimeOffset? NextRecheckDue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recommended action.
|
||||
/// </summary>
|
||||
[JsonPropertyName("recommendedAction")]
|
||||
public required string RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to renew an exception.
|
||||
/// </summary>
|
||||
public sealed record RenewExceptionRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope to renew.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new approver for the renewal.
|
||||
/// </summary>
|
||||
[JsonPropertyName("newApprover")]
|
||||
public required string NewApprover { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional updated justification.
|
||||
/// </summary>
|
||||
[JsonPropertyName("newJustification")]
|
||||
public string? NewJustification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Days to extend the expiry by.
|
||||
/// </summary>
|
||||
[JsonPropertyName("extendExpiryByDays")]
|
||||
public int? ExtendExpiryByDays { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to check recheck status of an exception.
|
||||
/// </summary>
|
||||
public sealed record CheckRecheckRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The DSSE envelope to check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("envelope")]
|
||||
public required DsseEnvelopeDto Envelope { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExceptionController.cs
|
||||
// Sprint: SPRINT_20260208_008_Attestor_dsse_signed_exception_objects_with_recheck_policy
|
||||
// Description: API endpoints for DSSE-signed exception operations.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.ProofChain.Services;
|
||||
using StellaOps.Attestor.ProofChain.Signing;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Attestor.WebService.Contracts;
|
||||
using StellaOps.Attestor.WebService.Options;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for DSSE-signed exception operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal/api/v1/exceptions")]
|
||||
[Produces("application/json")]
|
||||
[Authorize("attestor:write")]
|
||||
public class ExceptionController : ControllerBase
|
||||
{
|
||||
private readonly IExceptionSigningService _exceptionSigningService;
|
||||
private readonly ILogger<ExceptionController> _logger;
|
||||
private readonly AttestorWebServiceFeatures _features;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExceptionController"/> class.
|
||||
/// </summary>
|
||||
public ExceptionController(
|
||||
IExceptionSigningService exceptionSigningService,
|
||||
ILogger<ExceptionController> logger,
|
||||
IOptions<AttestorWebServiceFeatures>? features = null)
|
||||
{
|
||||
_exceptionSigningService = exceptionSigningService ?? throw new ArgumentNullException(nameof(exceptionSigningService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_features = features?.Value ?? new AttestorWebServiceFeatures();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signs an exception entry and wraps it in a DSSE envelope.
|
||||
/// </summary>
|
||||
/// <param name="request">The sign exception request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The signed exception response.</returns>
|
||||
[HttpPost("sign")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(SignedExceptionResponseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<SignedExceptionResponseDto>> SignExceptionAsync(
|
||||
[FromBody] SignExceptionRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Signing exception {ExceptionId} for subject {SubjectName}",
|
||||
request.Exception.ExceptionId,
|
||||
request.Subject.Name);
|
||||
|
||||
// Validate request
|
||||
if (string.IsNullOrWhiteSpace(request.Exception.ExceptionId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "ExceptionId is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Subject.Name))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "Subject name is required",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
// Map request to domain types
|
||||
var exception = MapToDomain(request.Exception);
|
||||
var subject = MapToDomain(request.Subject);
|
||||
var recheckPolicy = MapToDomain(request.RecheckPolicy);
|
||||
|
||||
var result = await _exceptionSigningService.SignExceptionAsync(
|
||||
exception,
|
||||
subject,
|
||||
recheckPolicy,
|
||||
request.Environments,
|
||||
request.CoveredViolationIds,
|
||||
renewsExceptionId: null,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var response = new SignedExceptionResponseDto
|
||||
{
|
||||
ExceptionContentId = result.ExceptionContentId,
|
||||
Envelope = MapToDto(result.Envelope),
|
||||
SignedAt = result.Statement.Predicate.SignedAt,
|
||||
Status = result.Statement.Predicate.Status.ToString(),
|
||||
NextRecheckAt = result.Statement.Predicate.RecheckPolicy.NextRecheckAt
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exception {ExceptionId} signed with content ID {ContentId}",
|
||||
request.Exception.ExceptionId,
|
||||
result.ExceptionContentId);
|
||||
|
||||
return CreatedAtAction(nameof(SignExceptionAsync), response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sign exception {ExceptionId}", request.Exception.ExceptionId);
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An error occurred while signing the exception",
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE-signed exception envelope.
|
||||
/// </summary>
|
||||
/// <param name="request">The verify exception request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
[HttpPost("verify")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(VerifyExceptionResponseDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<VerifyExceptionResponseDto>> VerifyExceptionAsync(
|
||||
[FromBody] VerifyExceptionRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Verifying exception envelope");
|
||||
|
||||
var envelope = MapToDomain(request.Envelope);
|
||||
var allowedKeyIds = request.AllowedKeyIds ?? Array.Empty<string>();
|
||||
|
||||
var result = await _exceptionSigningService.VerifyExceptionAsync(
|
||||
envelope,
|
||||
allowedKeyIds,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
RecheckStatusDto? recheckStatus = null;
|
||||
if (result.IsValid && result.Statement is not null)
|
||||
{
|
||||
var status = _exceptionSigningService.CheckRecheckRequired(result.Statement);
|
||||
recheckStatus = MapToDto(status);
|
||||
}
|
||||
|
||||
var response = new VerifyExceptionResponseDto
|
||||
{
|
||||
IsValid = result.IsValid,
|
||||
KeyId = result.KeyId,
|
||||
ExceptionContentId = result.Statement?.Predicate.ExceptionContentId,
|
||||
Error = result.Error,
|
||||
RecheckStatus = recheckStatus
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to verify exception envelope");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Verification Failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the recheck status of a signed exception.
|
||||
/// </summary>
|
||||
/// <param name="request">The check recheck request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The recheck status.</returns>
|
||||
[HttpPost("recheck-status")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(RecheckStatusDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<RecheckStatusDto>> CheckRecheckStatusAsync(
|
||||
[FromBody] CheckRecheckRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Checking recheck status for exception");
|
||||
|
||||
var envelope = MapToDomain(request.Envelope);
|
||||
|
||||
// First verify to get the statement
|
||||
var verifyResult = await _exceptionSigningService.VerifyExceptionAsync(
|
||||
envelope,
|
||||
Array.Empty<string>(),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!verifyResult.IsValid || verifyResult.Statement is null)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Envelope",
|
||||
Detail = verifyResult.Error ?? "Could not parse exception statement",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var status = _exceptionSigningService.CheckRecheckRequired(verifyResult.Statement);
|
||||
return Ok(MapToDto(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to check recheck status");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Check Failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renews an expired or expiring exception.
|
||||
/// </summary>
|
||||
/// <param name="request">The renew exception request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The renewed signed exception.</returns>
|
||||
[HttpPost("renew")]
|
||||
[EnableRateLimiting("attestor-submissions")]
|
||||
[ProducesResponseType(typeof(SignedExceptionResponseDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<SignedExceptionResponseDto>> RenewExceptionAsync(
|
||||
[FromBody] RenewExceptionRequestDto request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Renewing exception with new approver {Approver}", request.NewApprover);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewApprover))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid Request",
|
||||
Detail = "NewApprover is required for renewal",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var envelope = MapToDomain(request.Envelope);
|
||||
var extendBy = request.ExtendExpiryByDays.HasValue
|
||||
? TimeSpan.FromDays(request.ExtendExpiryByDays.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
var result = await _exceptionSigningService.RenewExceptionAsync(
|
||||
envelope,
|
||||
request.NewApprover,
|
||||
request.NewJustification,
|
||||
extendBy,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var response = new SignedExceptionResponseDto
|
||||
{
|
||||
ExceptionContentId = result.ExceptionContentId,
|
||||
Envelope = MapToDto(result.Envelope),
|
||||
SignedAt = result.Statement.Predicate.SignedAt,
|
||||
Status = result.Statement.Predicate.Status.ToString(),
|
||||
NextRecheckAt = result.Statement.Predicate.RecheckPolicy.NextRecheckAt
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Exception renewed with new content ID {ContentId}",
|
||||
result.ExceptionContentId);
|
||||
|
||||
return CreatedAtAction(nameof(RenewExceptionAsync), response);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Maximum renewal count"))
|
||||
{
|
||||
_logger.LogWarning(ex, "Maximum renewal count reached");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Renewal Limit Reached",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to renew exception");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
|
||||
{
|
||||
Title = "Internal Server Error",
|
||||
Detail = "An error occurred while renewing the exception",
|
||||
Status = StatusCodes.Status500InternalServerError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Mapping Methods ---
|
||||
|
||||
private static BudgetExceptionEntry MapToDomain(ExceptionEntryDto dto) => new()
|
||||
{
|
||||
ExceptionId = dto.ExceptionId,
|
||||
CoveredReasons = dto.CoveredReasons,
|
||||
CoveredTiers = dto.CoveredTiers,
|
||||
ExpiresAt = dto.ExpiresAt,
|
||||
Justification = dto.Justification,
|
||||
ApprovedBy = dto.ApprovedBy
|
||||
};
|
||||
|
||||
private static Subject MapToDomain(SubjectDto dto) => new()
|
||||
{
|
||||
Name = dto.Name,
|
||||
Digest = dto.Digest.ToDictionary(kv => kv.Key, kv => kv.Value)
|
||||
};
|
||||
|
||||
private static ExceptionRecheckPolicy MapToDomain(RecheckPolicyDto dto) => new()
|
||||
{
|
||||
RecheckIntervalDays = dto.RecheckIntervalDays,
|
||||
AutoRecheckEnabled = dto.AutoRecheckEnabled,
|
||||
MaxRenewalCount = dto.MaxRenewalCount,
|
||||
RequiresReapprovalOnExpiry = dto.RequiresReapprovalOnExpiry,
|
||||
ApprovalRoles = dto.ApprovalRoles
|
||||
};
|
||||
|
||||
private static DsseEnvelope MapToDomain(DsseEnvelopeDto dto) => new()
|
||||
{
|
||||
PayloadType = dto.PayloadType,
|
||||
Payload = dto.Payload,
|
||||
Signatures = dto.Signatures.Select(s => new DsseSignature
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static DsseEnvelopeDto MapToDto(DsseEnvelope envelope) => new()
|
||||
{
|
||||
PayloadType = envelope.PayloadType,
|
||||
Payload = envelope.Payload,
|
||||
Signatures = envelope.Signatures.Select(s => new DsseSignatureDto
|
||||
{
|
||||
KeyId = s.KeyId,
|
||||
Sig = s.Sig
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
private static RecheckStatusDto MapToDto(ExceptionRecheckStatus status) => new()
|
||||
{
|
||||
RecheckRequired = status.RecheckRequired,
|
||||
IsExpired = status.IsExpired,
|
||||
ExpiringWithinWarningWindow = status.ExpiringWithinWarningWindow,
|
||||
DaysUntilExpiry = status.DaysUntilExpiry,
|
||||
NextRecheckDue = status.NextRecheckDue,
|
||||
RecommendedAction = status.RecommendedAction.ToString()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user