feat: Implement EvidenceBundleAttestationBuilder with unit tests for claims generation and tenant validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user