Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundlesController.cs
|
||||
// Sprint: SPRINT_20251226_002_ATTESTOR_bundle_rotation
|
||||
// Task: 0010-0012 - Create bundle API endpoints
|
||||
// Description: API endpoints for attestation bundle management
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Attestor.Bundling.Abstractions;
|
||||
using StellaOps.Attestor.Bundling.Models;
|
||||
|
||||
namespace StellaOps.Attestor.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for attestation bundle management.
|
||||
/// Bundles aggregate attestations for a time period with optional org-key signing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/bundles")]
|
||||
[Produces("application/json")]
|
||||
[Authorize]
|
||||
public class BundlesController : ControllerBase
|
||||
{
|
||||
private readonly IAttestationBundler _bundler;
|
||||
private readonly ILogger<BundlesController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Create a new BundlesController.
|
||||
/// </summary>
|
||||
public BundlesController(
|
||||
IAttestationBundler bundler,
|
||||
ILogger<BundlesController> logger)
|
||||
{
|
||||
_bundler = bundler ?? throw new ArgumentNullException(nameof(bundler));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new attestation bundle for a time period.
|
||||
/// </summary>
|
||||
/// <param name="request">Bundle creation parameters.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The created bundle metadata.</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(BundleCreatedResponse), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<BundleCreatedResponse>> CreateBundleAsync(
|
||||
[FromBody] CreateBundleRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (request.PeriodEnd <= request.PeriodStart)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid period",
|
||||
Detail = "periodEnd must be after periodStart",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Creating bundle for period {Start} to {End}",
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd);
|
||||
|
||||
try
|
||||
{
|
||||
var creationRequest = new BundleCreationRequest(
|
||||
request.PeriodStart,
|
||||
request.PeriodEnd,
|
||||
request.TenantId,
|
||||
request.SignWithOrgKey,
|
||||
request.OrgKeyId);
|
||||
|
||||
var bundle = await _bundler.CreateBundleAsync(creationRequest, ct);
|
||||
|
||||
var response = new BundleCreatedResponse
|
||||
{
|
||||
BundleId = bundle.Metadata.BundleId,
|
||||
Status = "created",
|
||||
AttestationCount = bundle.Attestations.Count,
|
||||
PeriodStart = bundle.Metadata.PeriodStart,
|
||||
PeriodEnd = bundle.Metadata.PeriodEnd,
|
||||
CreatedAt = bundle.Metadata.CreatedAt,
|
||||
HasOrgSignature = bundle.OrgSignature != null
|
||||
};
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetBundleAsync),
|
||||
new { bundleId = bundle.Metadata.BundleId },
|
||||
response);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create bundle");
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle creation failed",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get bundle metadata by ID.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID (sha256:...).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Bundle metadata.</returns>
|
||||
[HttpGet("{bundleId}")]
|
||||
[ProducesResponseType(typeof(BundleMetadataResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleMetadataResponse>> GetBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsValidBundleId(bundleId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid bundle ID",
|
||||
Detail = "Bundle ID must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = $"No bundle found with ID {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new BundleMetadataResponse
|
||||
{
|
||||
BundleId = bundle.Metadata.BundleId,
|
||||
Version = bundle.Metadata.Version,
|
||||
PeriodStart = bundle.Metadata.PeriodStart,
|
||||
PeriodEnd = bundle.Metadata.PeriodEnd,
|
||||
AttestationCount = bundle.Metadata.AttestationCount,
|
||||
MerkleRoot = bundle.MerkleTree.Root,
|
||||
OrgSignature = bundle.OrgSignature != null
|
||||
? new OrgSignatureInfo
|
||||
{
|
||||
KeyId = bundle.OrgSignature.KeyId,
|
||||
Algorithm = bundle.OrgSignature.Algorithm,
|
||||
SignedAt = bundle.OrgSignature.SignedAt
|
||||
}
|
||||
: null,
|
||||
CreatedAt = bundle.Metadata.CreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List bundles with pagination.
|
||||
/// </summary>
|
||||
/// <param name="periodStart">Optional start of period filter.</param>
|
||||
/// <param name="periodEnd">Optional end of period filter.</param>
|
||||
/// <param name="tenantId">Optional tenant filter.</param>
|
||||
/// <param name="limit">Maximum results (default 20).</param>
|
||||
/// <param name="cursor">Pagination cursor.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Paginated list of bundles.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(BundleListResponse), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<BundleListResponse>> ListBundlesAsync(
|
||||
[FromQuery] DateTimeOffset? periodStart,
|
||||
[FromQuery] DateTimeOffset? periodEnd,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new BundleListRequest(
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tenantId,
|
||||
Math.Clamp(limit, 1, 100),
|
||||
cursor);
|
||||
|
||||
var result = await _bundler.ListBundlesAsync(request, ct);
|
||||
|
||||
var bundles = result.Bundles.Select(b => new BundleListItem
|
||||
{
|
||||
BundleId = b.BundleId,
|
||||
PeriodStart = b.PeriodStart,
|
||||
PeriodEnd = b.PeriodEnd,
|
||||
AttestationCount = b.AttestationCount,
|
||||
CreatedAt = b.CreatedAt,
|
||||
HasOrgSignature = b.HasOrgSignature
|
||||
}).ToList();
|
||||
|
||||
return Ok(new BundleListResponse
|
||||
{
|
||||
Bundles = bundles,
|
||||
NextCursor = result.NextCursor
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify bundle integrity and signatures.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
[HttpPost("{bundleId}/verify")]
|
||||
[ProducesResponseType(typeof(BundleVerifyResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundleVerifyResponse>> VerifyBundleAsync(
|
||||
[FromRoute] string bundleId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsValidBundleId(bundleId))
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid bundle ID",
|
||||
Detail = "Bundle ID must be in format sha256:<64-hex>",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = $"No bundle found with ID {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var result = await _bundler.VerifyBundleAsync(bundle, ct);
|
||||
|
||||
return Ok(new BundleVerifyResponse
|
||||
{
|
||||
Valid = result.Valid,
|
||||
MerkleRootVerified = result.MerkleRootVerified,
|
||||
OrgSignatureVerified = result.OrgSignatureVerified,
|
||||
AttestationsVerified = result.AttestationsVerified,
|
||||
Issues = result.Issues.Select(i => new BundleIssueDto
|
||||
{
|
||||
Severity = i.Severity.ToString().ToLowerInvariant(),
|
||||
Code = i.Code,
|
||||
Message = i.Message,
|
||||
EntryId = i.EntryId
|
||||
}).ToList(),
|
||||
VerifiedAt = result.VerifiedAt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a specific attestation from a bundle.
|
||||
/// </summary>
|
||||
/// <param name="bundleId">The bundle ID.</param>
|
||||
/// <param name="entryId">The attestation entry ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation.</returns>
|
||||
[HttpGet("{bundleId}/attestations/{entryId}")]
|
||||
[ProducesResponseType(typeof(BundledAttestation), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<BundledAttestation>> GetAttestationAsync(
|
||||
[FromRoute] string bundleId,
|
||||
[FromRoute] string entryId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var bundle = await _bundler.GetBundleAsync(bundleId, ct);
|
||||
|
||||
if (bundle == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = $"No bundle found with ID {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var attestation = bundle.Attestations.FirstOrDefault(a =>
|
||||
string.Equals(a.EntryId, entryId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (attestation == null)
|
||||
{
|
||||
return NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Attestation not found",
|
||||
Detail = $"No attestation found with entry ID {entryId} in bundle {bundleId}",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(attestation);
|
||||
}
|
||||
|
||||
private static bool IsValidBundleId(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
if (!value.StartsWith("sha256:", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var hex = value.AsSpan()["sha256:".Length..];
|
||||
if (hex.Length != 64)
|
||||
return false;
|
||||
|
||||
foreach (var c in hex)
|
||||
{
|
||||
if (c is not ((>= '0' and <= '9') or (>= 'a' and <= 'f')))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Request to create a bundle.</summary>
|
||||
public sealed record CreateBundleRequest
|
||||
{
|
||||
/// <summary>Start of attestation collection period.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>End of attestation collection period.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Optional tenant ID filter.</summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>Whether to sign with organization key.</summary>
|
||||
public bool SignWithOrgKey { get; init; } = true;
|
||||
|
||||
/// <summary>Organization key ID to use (uses active key if not specified).</summary>
|
||||
public string? OrgKeyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Response after bundle creation.</summary>
|
||||
public sealed record BundleCreatedResponse
|
||||
{
|
||||
/// <summary>The created bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Creation status.</summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Number of attestations in the bundle.</summary>
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>Period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>When the bundle was created.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Whether the bundle has an org signature.</summary>
|
||||
public required bool HasOrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle metadata response.</summary>
|
||||
public sealed record BundleMetadataResponse
|
||||
{
|
||||
/// <summary>Bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Schema version.</summary>
|
||||
public required string Version { get; init; }
|
||||
|
||||
/// <summary>Period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Number of attestations.</summary>
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>Merkle root.</summary>
|
||||
public required string MerkleRoot { get; init; }
|
||||
|
||||
/// <summary>Org signature info if present.</summary>
|
||||
public OrgSignatureInfo? OrgSignature { get; init; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Org signature info.</summary>
|
||||
public sealed record OrgSignatureInfo
|
||||
{
|
||||
/// <summary>Key ID.</summary>
|
||||
public required string KeyId { get; init; }
|
||||
|
||||
/// <summary>Algorithm.</summary>
|
||||
public required string Algorithm { get; init; }
|
||||
|
||||
/// <summary>When signed.</summary>
|
||||
public required DateTimeOffset SignedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle list response.</summary>
|
||||
public sealed record BundleListResponse
|
||||
{
|
||||
/// <summary>The bundles.</summary>
|
||||
public required IReadOnlyList<BundleListItem> Bundles { get; init; }
|
||||
|
||||
/// <summary>Next page cursor.</summary>
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle list item.</summary>
|
||||
public sealed record BundleListItem
|
||||
{
|
||||
/// <summary>Bundle ID.</summary>
|
||||
public required string BundleId { get; init; }
|
||||
|
||||
/// <summary>Period start.</summary>
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
|
||||
/// <summary>Period end.</summary>
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
|
||||
/// <summary>Attestation count.</summary>
|
||||
public required int AttestationCount { get; init; }
|
||||
|
||||
/// <summary>Creation time.</summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>Whether has org signature.</summary>
|
||||
public required bool HasOrgSignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle verification response.</summary>
|
||||
public sealed record BundleVerifyResponse
|
||||
{
|
||||
/// <summary>Overall validity.</summary>
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
/// <summary>Merkle root verified.</summary>
|
||||
public required bool MerkleRootVerified { get; init; }
|
||||
|
||||
/// <summary>Org signature verified (if present).</summary>
|
||||
public bool? OrgSignatureVerified { get; init; }
|
||||
|
||||
/// <summary>Number of attestations verified.</summary>
|
||||
public required int AttestationsVerified { get; init; }
|
||||
|
||||
/// <summary>Issues found.</summary>
|
||||
public required IReadOnlyList<BundleIssueDto> Issues { get; init; }
|
||||
|
||||
/// <summary>Verification timestamp.</summary>
|
||||
public required DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bundle issue DTO.</summary>
|
||||
public sealed record BundleIssueDto
|
||||
{
|
||||
/// <summary>Issue severity.</summary>
|
||||
public required string Severity { get; init; }
|
||||
|
||||
/// <summary>Issue code.</summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>Issue message.</summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>Related entry ID.</summary>
|
||||
public string? EntryId { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -28,5 +28,6 @@
|
||||
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Attestor.StandardPredicates/StellaOps.Attestor.StandardPredicates.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Attestor.Bundling\StellaOps.Attestor.Bundling.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user