feat: Implement EvidenceBundleAttestationBuilder with unit tests for claims generation and tenant validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
master
2025-11-20 09:17:58 +02:00
parent 10212d67c0
commit f0e74d2ee8
4 changed files with 219 additions and 1 deletions

View File

@@ -0,0 +1,144 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Concelier.Core.Attestation;
public sealed record AttestationTransparency(string? RekorUuid, string? SkipReason);
public sealed record AttestationClaims(
string SubjectName,
string SubjectDigest,
string BundleId,
string BundleVersion,
DateTimeOffset BundleCreated,
string PipelineVersion,
IReadOnlyCollection<string> PipelineInputs,
string Tenant,
string Scope,
string EvidenceBundlePath,
AttestationTransparency Transparency,
bool AocGuardrails,
IReadOnlyCollection<string> AocDetails);
public sealed record EvidenceBundleAttestationRequest(
string BundlePath,
string ManifestPath,
string? TransparencyPath,
string PipelineVersion);
public sealed class EvidenceBundleAttestationBuilder
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public async Task<AttestationClaims> BuildAsync(EvidenceBundleAttestationRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(request.BundlePath))
{
throw new ArgumentException("Bundle path is required", nameof(request));
}
if (string.IsNullOrWhiteSpace(request.ManifestPath))
{
throw new ArgumentException("Manifest path is required", nameof(request));
}
var manifest = await ReadManifestAsync(request.ManifestPath, cancellationToken).ConfigureAwait(false);
ValidateTenant(manifest.Tenant);
var bundleDigest = await ComputeSha256Async(request.BundlePath, cancellationToken).ConfigureAwait(false);
var pipelineInputs = manifest.Inputs ?? Array.Empty<string>();
var transparency = await ReadTransparencyAsync(request.TransparencyPath, cancellationToken).ConfigureAwait(false)
?? new TransparencyPayload { SkipReason = "offline" };
return new AttestationClaims(
SubjectName: manifest.BundleId ?? string.Empty,
SubjectDigest: $"sha256:{bundleDigest}",
BundleId: manifest.BundleId ?? string.Empty,
BundleVersion: manifest.Version ?? string.Empty,
BundleCreated: manifest.Created,
PipelineVersion: request.PipelineVersion,
PipelineInputs: pipelineInputs,
Tenant: manifest.Tenant ?? string.Empty,
Scope: manifest.Scope ?? string.Empty,
EvidenceBundlePath: request.BundlePath,
Transparency: new AttestationTransparency(transparency.RekorUuid, transparency.SkipReason ?? "offline"),
AocGuardrails: manifest.Aoc?.Guardrails ?? false,
AocDetails: manifest.Aoc?.Details ?? Array.Empty<string>());
}
private static async Task<ManifestPayload> ReadManifestAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var manifest = await JsonSerializer.DeserializeAsync<ManifestPayload>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
return manifest ?? throw new InvalidOperationException("Manifest payload could not be read.");
}
private static async Task<TransparencyPayload?> ReadTransparencyAsync(string? path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
await using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<TransparencyPayload>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
}
private static void ValidateTenant(string? tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
{
throw new InvalidOperationException("Tenant must be present in evidence bundle manifest.");
}
if (!string.Equals(tenant, tenant.ToLowerInvariant(), StringComparison.Ordinal))
{
throw new InvalidOperationException("Tenant must be lowercase to satisfy attestation scope requirements.");
}
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private sealed record ManifestPayload
{
[JsonPropertyName("bundle_id")] public string? BundleId { get; init; }
[JsonPropertyName("version")] public string? Version { get; init; }
[JsonPropertyName("created")] public DateTimeOffset Created { get; init; }
[JsonPropertyName("tenant")] public string? Tenant { get; init; }
[JsonPropertyName("scope")] public string? Scope { get; init; }
[JsonPropertyName("inputs")] public IReadOnlyCollection<string>? Inputs { get; init; }
[JsonPropertyName("aoc")] public AocPayload? Aoc { get; init; }
}
private sealed record AocPayload
{
[JsonPropertyName("guardrails")] public bool Guardrails { get; init; }
[JsonPropertyName("details")] public IReadOnlyCollection<string>? Details { get; init; }
}
private sealed record TransparencyPayload
{
[JsonPropertyName("rekor_uuid")] public string? RekorUuid { get; init; }
[JsonPropertyName("skip_reason")] public string? SkipReason { get; init; }
}
}