partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -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>();

View File

@@ -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; }
}

View File

@@ -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()
};
}