save checkpoint. addition features and their state. check some ofthem
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
namespace StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
public sealed record EvidenceProducerBundle
|
||||
{
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
public required string CanonicalBomSha256 { get; init; }
|
||||
|
||||
public required string DsseEnvelopeRef { get; init; }
|
||||
|
||||
public required string PayloadDigest { get; init; }
|
||||
|
||||
public required long RekorIndex { get; init; }
|
||||
|
||||
public required string RekorTileId { get; init; }
|
||||
|
||||
public required string RekorInclusionProofRef { get; init; }
|
||||
|
||||
public IReadOnlyList<string> AttestationRefs { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record EvidenceGateArtifactSubmission
|
||||
{
|
||||
public required EvidenceProducerBundle ProducerBundle { get; init; }
|
||||
|
||||
public string? RawBomRef { get; init; }
|
||||
|
||||
public IReadOnlyList<string> VexRefs { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record EvidenceGateArtifactRecord(
|
||||
string EvidenceId,
|
||||
TenantId TenantId,
|
||||
string ArtifactId,
|
||||
string CanonicalBomSha256,
|
||||
string PayloadDigest,
|
||||
string DsseEnvelopeRef,
|
||||
long RekorIndex,
|
||||
string RekorTileId,
|
||||
string RekorInclusionProofRef,
|
||||
IReadOnlyList<string> AttestationRefs,
|
||||
string? RawBomRef,
|
||||
IReadOnlyList<string> VexRefs,
|
||||
string EvidenceScore,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record EvidenceGateArtifactStoreResult(
|
||||
string EvidenceId,
|
||||
string EvidenceScore,
|
||||
bool Stored);
|
||||
|
||||
public sealed record EvidenceGateArtifactScoreResult(
|
||||
string ArtifactId,
|
||||
string EvidenceScore,
|
||||
string Status);
|
||||
@@ -0,0 +1,15 @@
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Repositories;
|
||||
|
||||
public interface IEvidenceGateArtifactRepository
|
||||
{
|
||||
Task<EvidenceGateArtifactRecord> UpsertAsync(
|
||||
EvidenceGateArtifactRecord record,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<EvidenceGateArtifactRecord?> GetByArtifactIdAsync(
|
||||
TenantId tenantId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -12,3 +12,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| REINDEX-004 | DONE | Reindex service root recomputation (2026-01-16). |
|
||||
| REINDEX-005 | DONE | Cross-reference mapping (2026-01-16). |
|
||||
| REINDEX-006 | DONE | Continuity verification (2026-01-16). |
|
||||
| EL-GATE-001 | DONE | Added gate artifact domain contracts + deterministic evidence score model (2026-02-09). |
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 004_gate_artifacts.sql
|
||||
-- Adds deterministic gate artifact evidence score persistence.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evidence_locker.evidence_gate_artifacts
|
||||
(
|
||||
evidence_id text NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
artifact_id text NOT NULL,
|
||||
canonical_bom_sha256 text NOT NULL CHECK (canonical_bom_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
payload_digest text NOT NULL CHECK (payload_digest ~ '^[0-9a-f]{64}$'),
|
||||
dsse_envelope_ref text NOT NULL,
|
||||
rekor_index bigint NOT NULL CHECK (rekor_index >= 0),
|
||||
rekor_tile_id text NOT NULL,
|
||||
rekor_inclusion_proof_ref text NOT NULL,
|
||||
attestation_refs jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(attestation_refs) = 'array'),
|
||||
raw_bom_ref text,
|
||||
vex_refs jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(vex_refs) = 'array'),
|
||||
evidence_score text NOT NULL CHECK (evidence_score ~ '^[0-9a-f]{64}$'),
|
||||
created_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
updated_at timestamptz NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT pk_evidence_gate_artifacts PRIMARY KEY (tenant_id, artifact_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_evidence_gate_artifacts_evidence_id
|
||||
ON evidence_locker.evidence_gate_artifacts (evidence_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_evidence_gate_artifacts_tenant_score
|
||||
ON evidence_locker.evidence_gate_artifacts (tenant_id, evidence_score);
|
||||
|
||||
ALTER TABLE evidence_locker.evidence_gate_artifacts
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE evidence_locker.evidence_gate_artifacts
|
||||
FORCE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'evidence_locker'
|
||||
AND tablename = 'evidence_gate_artifacts'
|
||||
AND policyname = 'evidence_gate_artifacts_isolation') THEN
|
||||
CREATE POLICY evidence_gate_artifacts_isolation
|
||||
ON evidence_locker.evidence_gate_artifacts
|
||||
USING (tenant_id = evidence_locker_app.require_current_tenant())
|
||||
WITH CHECK (tenant_id = evidence_locker_app.require_current_tenant());
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -77,6 +77,7 @@ public static class EvidenceLockerInfrastructureServiceCollectionExtensions
|
||||
});
|
||||
services.AddScoped<IEvidenceBundleBuilder, EvidenceBundleBuilder>();
|
||||
services.AddScoped<IEvidenceBundleRepository, EvidenceBundleRepository>();
|
||||
services.AddScoped<IEvidenceGateArtifactRepository, EvidenceGateArtifactRepository>();
|
||||
services.AddScoped<IEvidenceReindexService, EvidenceReindexService>();
|
||||
|
||||
// Verdict attestation repository
|
||||
@@ -136,6 +137,7 @@ public static class EvidenceLockerInfrastructureServiceCollectionExtensions
|
||||
services.TryAddSingleton<ITimestampAuthorityClient, NullTimestampAuthorityClient>();
|
||||
services.AddScoped<IEvidenceSignatureService, EvidenceSignatureService>();
|
||||
services.AddScoped<EvidenceSnapshotService>();
|
||||
services.AddScoped<EvidenceGateArtifactService>();
|
||||
services.AddScoped<EvidenceBundlePackagingService>();
|
||||
services.AddScoped<EvidencePortableBundleService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Repositories;
|
||||
|
||||
internal sealed class EvidenceGateArtifactRepository(EvidenceLockerDataSource dataSource) : IEvidenceGateArtifactRepository
|
||||
{
|
||||
private const string UpsertSql = """
|
||||
INSERT INTO evidence_locker.evidence_gate_artifacts
|
||||
(evidence_id, tenant_id, artifact_id, canonical_bom_sha256, payload_digest, dsse_envelope_ref, rekor_index, rekor_tile_id, rekor_inclusion_proof_ref, attestation_refs, raw_bom_ref, vex_refs, evidence_score, created_at, updated_at)
|
||||
VALUES
|
||||
(@evidence_id, @tenant_id, @artifact_id, @canonical_bom_sha256, @payload_digest, @dsse_envelope_ref, @rekor_index, @rekor_tile_id, @rekor_inclusion_proof_ref, @attestation_refs, @raw_bom_ref, @vex_refs, @evidence_score, @created_at, @updated_at)
|
||||
ON CONFLICT (tenant_id, artifact_id)
|
||||
DO UPDATE SET
|
||||
evidence_id = EXCLUDED.evidence_id,
|
||||
canonical_bom_sha256 = EXCLUDED.canonical_bom_sha256,
|
||||
payload_digest = EXCLUDED.payload_digest,
|
||||
dsse_envelope_ref = EXCLUDED.dsse_envelope_ref,
|
||||
rekor_index = EXCLUDED.rekor_index,
|
||||
rekor_tile_id = EXCLUDED.rekor_tile_id,
|
||||
rekor_inclusion_proof_ref = EXCLUDED.rekor_inclusion_proof_ref,
|
||||
attestation_refs = EXCLUDED.attestation_refs,
|
||||
raw_bom_ref = EXCLUDED.raw_bom_ref,
|
||||
vex_refs = EXCLUDED.vex_refs,
|
||||
evidence_score = EXCLUDED.evidence_score,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING evidence_id, tenant_id, artifact_id, canonical_bom_sha256, payload_digest, dsse_envelope_ref, rekor_index, rekor_tile_id, rekor_inclusion_proof_ref, attestation_refs, raw_bom_ref, vex_refs, evidence_score, created_at, updated_at;
|
||||
""";
|
||||
|
||||
private const string SelectByArtifactSql = """
|
||||
SELECT evidence_id, tenant_id, artifact_id, canonical_bom_sha256, payload_digest, dsse_envelope_ref, rekor_index, rekor_tile_id, rekor_inclusion_proof_ref, attestation_refs, raw_bom_ref, vex_refs, evidence_score, created_at, updated_at
|
||||
FROM evidence_locker.evidence_gate_artifacts
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND artifact_id = @artifact_id;
|
||||
""";
|
||||
|
||||
public async Task<EvidenceGateArtifactRecord> UpsertAsync(
|
||||
EvidenceGateArtifactRecord record,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(record.TenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(UpsertSql, connection);
|
||||
command.Parameters.AddWithValue("evidence_id", record.EvidenceId);
|
||||
command.Parameters.AddWithValue("tenant_id", record.TenantId.Value);
|
||||
command.Parameters.AddWithValue("artifact_id", record.ArtifactId);
|
||||
command.Parameters.AddWithValue("canonical_bom_sha256", record.CanonicalBomSha256);
|
||||
command.Parameters.AddWithValue("payload_digest", record.PayloadDigest);
|
||||
command.Parameters.AddWithValue("dsse_envelope_ref", record.DsseEnvelopeRef);
|
||||
command.Parameters.AddWithValue("rekor_index", record.RekorIndex);
|
||||
command.Parameters.AddWithValue("rekor_tile_id", record.RekorTileId);
|
||||
command.Parameters.AddWithValue("rekor_inclusion_proof_ref", record.RekorInclusionProofRef);
|
||||
command.Parameters.AddWithValue("raw_bom_ref", (object?)record.RawBomRef ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("evidence_score", record.EvidenceScore);
|
||||
command.Parameters.AddWithValue("created_at", record.CreatedAt.UtcDateTime);
|
||||
command.Parameters.AddWithValue("updated_at", record.UpdatedAt.UtcDateTime);
|
||||
|
||||
var attestationParameter = command.Parameters.Add("attestation_refs", NpgsqlDbType.Jsonb);
|
||||
attestationParameter.Value = JsonSerializer.Serialize(record.AttestationRefs);
|
||||
|
||||
var vexParameter = command.Parameters.Add("vex_refs", NpgsqlDbType.Jsonb);
|
||||
vexParameter.Value = JsonSerializer.Serialize(record.VexRefs);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
await reader.ReadAsync(cancellationToken);
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
public async Task<EvidenceGateArtifactRecord?> GetByArtifactIdAsync(
|
||||
TenantId tenantId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(SelectByArtifactSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
command.Parameters.AddWithValue("artifact_id", artifactId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapRecord(reader);
|
||||
}
|
||||
|
||||
private static EvidenceGateArtifactRecord MapRecord(NpgsqlDataReader reader)
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(reader.GetGuid(1));
|
||||
var attestationRefs = DeserializeStringArray(reader.GetString(9));
|
||||
var rawBomRef = reader.IsDBNull(10) ? null : reader.GetString(10);
|
||||
var vexRefs = DeserializeStringArray(reader.GetString(11));
|
||||
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(13), DateTimeKind.Utc));
|
||||
var updatedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(14), DateTimeKind.Utc));
|
||||
|
||||
return new EvidenceGateArtifactRecord(
|
||||
reader.GetString(0),
|
||||
tenantId,
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetString(4),
|
||||
reader.GetString(5),
|
||||
reader.GetInt64(6),
|
||||
reader.GetString(7),
|
||||
reader.GetString(8),
|
||||
attestationRefs,
|
||||
rawBomRef,
|
||||
vexRefs,
|
||||
reader.GetString(12),
|
||||
createdAt,
|
||||
updatedAt);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DeserializeStringArray(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<List<string>>(json) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
|
||||
public sealed class EvidenceGateArtifactService
|
||||
{
|
||||
private const char EvidenceScoreSeparator = '\u001f';
|
||||
private readonly IEvidenceGateArtifactRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public EvidenceGateArtifactService(
|
||||
IEvidenceGateArtifactRepository repository,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider? guidProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
public async Task<EvidenceGateArtifactStoreResult> StoreAsync(
|
||||
TenantId tenantId,
|
||||
EvidenceGateArtifactSubmission submission,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(submission);
|
||||
ArgumentNullException.ThrowIfNull(submission.ProducerBundle);
|
||||
|
||||
var bundle = submission.ProducerBundle;
|
||||
var artifactId = NormalizeRequiredValue(bundle.ArtifactId, "artifact_id");
|
||||
var canonicalBomSha256 = NormalizeDigest(bundle.CanonicalBomSha256, "canonical_bom_sha256");
|
||||
var payloadDigest = NormalizeDigest(bundle.PayloadDigest, "payload_digest");
|
||||
var dsseEnvelopeRef = NormalizeRequiredValue(bundle.DsseEnvelopeRef, "dsse_envelope_path");
|
||||
var rekorTileId = NormalizeRequiredValue(bundle.RekorTileId, "rekor.tile_id");
|
||||
var rekorInclusionProofRef = NormalizeRequiredValue(bundle.RekorInclusionProofRef, "rekor.inclusion_proof_path");
|
||||
|
||||
if (bundle.RekorIndex < 0)
|
||||
{
|
||||
throw new InvalidOperationException("rekor.index must be greater than or equal to 0.");
|
||||
}
|
||||
|
||||
var sortedAttestationRefs = NormalizeReferences(bundle.AttestationRefs, "attestation_refs")
|
||||
.OrderBy(static reference => reference, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var sortedVexRefs = NormalizeReferences(submission.VexRefs, "vex_refs")
|
||||
.OrderBy(static reference => reference, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var rawBomRef = NormalizeOptionalValue(submission.RawBomRef);
|
||||
|
||||
var evidenceScore = ComputeEvidenceScore(canonicalBomSha256, payloadDigest, sortedAttestationRefs);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var record = new EvidenceGateArtifactRecord(
|
||||
BuildEvidenceId(now),
|
||||
tenantId,
|
||||
artifactId,
|
||||
canonicalBomSha256,
|
||||
payloadDigest,
|
||||
dsseEnvelopeRef,
|
||||
bundle.RekorIndex,
|
||||
rekorTileId,
|
||||
rekorInclusionProofRef,
|
||||
sortedAttestationRefs,
|
||||
rawBomRef,
|
||||
sortedVexRefs,
|
||||
evidenceScore,
|
||||
now,
|
||||
now);
|
||||
|
||||
var persisted = await _repository.UpsertAsync(record, cancellationToken).ConfigureAwait(false);
|
||||
return new EvidenceGateArtifactStoreResult(
|
||||
persisted.EvidenceId,
|
||||
persisted.EvidenceScore,
|
||||
Stored: true);
|
||||
}
|
||||
|
||||
public async Task<EvidenceGateArtifactScoreResult?> GetScoreAsync(
|
||||
TenantId tenantId,
|
||||
string artifactId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("Tenant identifier is required.", nameof(tenantId));
|
||||
}
|
||||
|
||||
var normalizedArtifactId = NormalizeRequiredValue(artifactId, "artifact_id");
|
||||
var record = await _repository.GetByArtifactIdAsync(tenantId, normalizedArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new EvidenceGateArtifactScoreResult(
|
||||
record.ArtifactId,
|
||||
record.EvidenceScore,
|
||||
Status: "ready");
|
||||
}
|
||||
|
||||
internal static string ComputeEvidenceScore(
|
||||
string canonicalBomSha256,
|
||||
string payloadDigest,
|
||||
IReadOnlyList<string> sortedAttestationRefs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sortedAttestationRefs);
|
||||
var parts = new List<string>(2 + sortedAttestationRefs.Count)
|
||||
{
|
||||
canonicalBomSha256,
|
||||
payloadDigest
|
||||
};
|
||||
parts.AddRange(sortedAttestationRefs);
|
||||
|
||||
var input = string.Join(EvidenceScoreSeparator, parts);
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(digest).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string BuildEvidenceId(DateTimeOffset timestamp)
|
||||
=> $"ev:{timestamp:yyyy-MM-dd}:{_guidProvider.NewGuid():N}";
|
||||
|
||||
private static string NormalizeDigest(string value, string fieldName)
|
||||
{
|
||||
var normalized = NormalizeRequiredValue(value, fieldName).ToLowerInvariant();
|
||||
if (normalized.Length != 64 || !IsHex(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"{fieldName} must be a 64-character hexadecimal digest.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeReferences(IReadOnlyList<string>? values, string fieldName)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalized = new List<string>(values.Count);
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
var value = values[i];
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException($"{fieldName}[{i}] must be a non-empty string.");
|
||||
}
|
||||
|
||||
normalized.Add(value.Trim());
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string NormalizeRequiredValue(string value, string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException($"{fieldName} is required.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static bool IsHex(string value)
|
||||
{
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var ch = value[i];
|
||||
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f';
|
||||
if (!isHex)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0289-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0289-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0289-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| EL-GATE-002 | DONE | Added `evidence_gate_artifacts` persistence, migration `004_gate_artifacts.sql`, and repository/service wiring (2026-02-09). |
|
||||
|
||||
@@ -50,6 +50,7 @@ public sealed class DatabaseMigrationTests : IClassFixture<PostgreSqlFixture>
|
||||
|
||||
Assert.Contains("evidence_artifacts", tables);
|
||||
Assert.Contains("evidence_bundles", tables);
|
||||
Assert.Contains("evidence_gate_artifacts", tables);
|
||||
Assert.Contains("evidence_holds", tables);
|
||||
Assert.Contains("evidence_schema_version", tables);
|
||||
|
||||
@@ -59,6 +60,12 @@ public sealed class DatabaseMigrationTests : IClassFixture<PostgreSqlFixture>
|
||||
var applied = Convert.ToInt64(await versionCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
|
||||
Assert.Equal(1, applied);
|
||||
|
||||
await using var versionFourCommand = new NpgsqlCommand(
|
||||
"SELECT COUNT(*) FROM evidence_locker.evidence_schema_version WHERE version = 4;",
|
||||
connection);
|
||||
var appliedVersionFour = Convert.ToInt64(await versionFourCommand.ExecuteScalarAsync(cancellationToken) ?? 0L);
|
||||
Assert.Equal(1, appliedVersionFour);
|
||||
|
||||
var tenant = TenantId.FromGuid(Guid.NewGuid());
|
||||
await using var tenantConnection = await _fixture.DataSource.OpenConnectionAsync(tenant, cancellationToken);
|
||||
await using var insertCommand = new NpgsqlCommand(@"
|
||||
@@ -103,4 +110,3 @@ public sealed class DatabaseMigrationTests : IClassFixture<PostgreSqlFixture>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
public sealed class EvidenceGateArtifactServiceTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_SortsAttestationReferencesForDeterministicScore()
|
||||
{
|
||||
var repository = new InMemoryGateArtifactRepository();
|
||||
var service = new EvidenceGateArtifactService(repository, TimeProvider.System);
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
const string artifactId = "stella://svc/orders@sha256:abc";
|
||||
|
||||
var first = await service.StoreAsync(
|
||||
tenantId,
|
||||
new EvidenceGateArtifactSubmission
|
||||
{
|
||||
ProducerBundle = new EvidenceProducerBundle
|
||||
{
|
||||
ArtifactId = artifactId,
|
||||
CanonicalBomSha256 = new string('a', 64),
|
||||
DsseEnvelopeRef = "blob://evidence/dsse/1.json",
|
||||
PayloadDigest = new string('a', 64),
|
||||
RekorIndex = 1,
|
||||
RekorTileId = "v2/tiles/0001/0001",
|
||||
RekorInclusionProofRef = "blob://proof/1.json",
|
||||
AttestationRefs =
|
||||
[
|
||||
"sha256://z",
|
||||
"sha256://a"
|
||||
]
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
var second = await service.StoreAsync(
|
||||
tenantId,
|
||||
new EvidenceGateArtifactSubmission
|
||||
{
|
||||
ProducerBundle = new EvidenceProducerBundle
|
||||
{
|
||||
ArtifactId = artifactId,
|
||||
CanonicalBomSha256 = new string('a', 64),
|
||||
DsseEnvelopeRef = "blob://evidence/dsse/1.json",
|
||||
PayloadDigest = new string('a', 64),
|
||||
RekorIndex = 1,
|
||||
RekorTileId = "v2/tiles/0001/0001",
|
||||
RekorInclusionProofRef = "blob://proof/1.json",
|
||||
AttestationRefs =
|
||||
[
|
||||
"sha256://a",
|
||||
"sha256://z"
|
||||
]
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(first.EvidenceScore, second.EvidenceScore);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_InvalidDigest_ThrowsValidationError()
|
||||
{
|
||||
var repository = new InMemoryGateArtifactRepository();
|
||||
var service = new EvidenceGateArtifactService(repository, TimeProvider.System);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.StoreAsync(
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
new EvidenceGateArtifactSubmission
|
||||
{
|
||||
ProducerBundle = new EvidenceProducerBundle
|
||||
{
|
||||
ArtifactId = "stella://svc/orders@sha256:abc",
|
||||
CanonicalBomSha256 = "bad",
|
||||
DsseEnvelopeRef = "blob://evidence/dsse/1.json",
|
||||
PayloadDigest = new string('a', 64),
|
||||
RekorIndex = 1,
|
||||
RekorTileId = "v2/tiles/0001/0001",
|
||||
RekorInclusionProofRef = "blob://proof/1.json",
|
||||
AttestationRefs = ["sha256://a"]
|
||||
}
|
||||
},
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("canonical_bom_sha256", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetScoreAsync_MissingArtifact_ReturnsNull()
|
||||
{
|
||||
var repository = new InMemoryGateArtifactRepository();
|
||||
var service = new EvidenceGateArtifactService(repository, TimeProvider.System);
|
||||
var score = await service.GetScoreAsync(
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
"stella://svc/missing@sha256:123",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(score);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task StoreAsync_WhitespaceAttestationRef_ThrowsValidationError()
|
||||
{
|
||||
var repository = new InMemoryGateArtifactRepository();
|
||||
var service = new EvidenceGateArtifactService(repository, TimeProvider.System);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.StoreAsync(
|
||||
TenantId.FromGuid(Guid.NewGuid()),
|
||||
new EvidenceGateArtifactSubmission
|
||||
{
|
||||
ProducerBundle = new EvidenceProducerBundle
|
||||
{
|
||||
ArtifactId = "stella://svc/orders@sha256:abc",
|
||||
CanonicalBomSha256 = new string('a', 64),
|
||||
DsseEnvelopeRef = "blob://evidence/dsse/1.json",
|
||||
PayloadDigest = new string('a', 64),
|
||||
RekorIndex = 1,
|
||||
RekorTileId = "v2/tiles/0001/0001",
|
||||
RekorInclusionProofRef = "blob://proof/1.json",
|
||||
AttestationRefs =
|
||||
[
|
||||
"sha256://valid",
|
||||
" "
|
||||
]
|
||||
}
|
||||
},
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("attestation_refs[1]", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InMemoryGateArtifactRepository : IEvidenceGateArtifactRepository
|
||||
{
|
||||
private readonly Dictionary<(Guid TenantId, string ArtifactId), EvidenceGateArtifactRecord> _records = new();
|
||||
|
||||
public Task<EvidenceGateArtifactRecord> UpsertAsync(EvidenceGateArtifactRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
_records[(record.TenantId.Value, record.ArtifactId)] = record;
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
|
||||
public Task<EvidenceGateArtifactRecord?> GetByArtifactIdAsync(TenantId tenantId, string artifactId, CancellationToken cancellationToken)
|
||||
{
|
||||
_records.TryGetValue((tenantId.Value, artifactId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
||||
}
|
||||
|
||||
public TestEvidenceBundleRepository Repository => Services.GetRequiredService<TestEvidenceBundleRepository>();
|
||||
public TestEvidenceGateArtifactRepository GateArtifactRepository => Services.GetRequiredService<TestEvidenceGateArtifactRepository>();
|
||||
public TestEvidenceObjectStore ObjectStore => Services.GetRequiredService<TestEvidenceObjectStore>();
|
||||
|
||||
public TestTimelinePublisher TimelinePublisher => Services.GetRequiredService<TestTimelinePublisher>();
|
||||
@@ -58,6 +59,7 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
||||
public void ResetTestState()
|
||||
{
|
||||
Repository.Reset();
|
||||
GateArtifactRepository.Reset();
|
||||
ObjectStore.Reset();
|
||||
TimelinePublisher.Reset();
|
||||
}
|
||||
@@ -111,6 +113,7 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IEvidenceBundleRepository>();
|
||||
services.RemoveAll<IEvidenceGateArtifactRepository>();
|
||||
services.RemoveAll<IEvidenceTimelinePublisher>();
|
||||
services.RemoveAll<ITimestampAuthorityClient>();
|
||||
services.RemoveAll<IEvidenceObjectStore>();
|
||||
@@ -121,6 +124,8 @@ public sealed class EvidenceLockerWebApplicationFactory : WebApplicationFactory<
|
||||
|
||||
services.AddSingleton<TestEvidenceBundleRepository>();
|
||||
services.AddSingleton<IEvidenceBundleRepository>(sp => sp.GetRequiredService<TestEvidenceBundleRepository>());
|
||||
services.AddSingleton<TestEvidenceGateArtifactRepository>();
|
||||
services.AddSingleton<IEvidenceGateArtifactRepository>(sp => sp.GetRequiredService<TestEvidenceGateArtifactRepository>());
|
||||
services.AddSingleton<TestTimelinePublisher>();
|
||||
services.AddSingleton<IEvidenceTimelinePublisher>(sp => sp.GetRequiredService<TestTimelinePublisher>());
|
||||
services.AddSingleton<ITimestampAuthorityClient, TestTimestampAuthorityClient>();
|
||||
@@ -417,6 +422,26 @@ public sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TestEvidenceGateArtifactRepository : IEvidenceGateArtifactRepository
|
||||
{
|
||||
private readonly Dictionary<(Guid TenantId, string ArtifactId), EvidenceGateArtifactRecord> _records = new();
|
||||
|
||||
public void Reset() => _records.Clear();
|
||||
|
||||
public Task<EvidenceGateArtifactRecord> UpsertAsync(EvidenceGateArtifactRecord record, CancellationToken cancellationToken)
|
||||
{
|
||||
var stored = record with { UpdatedAt = DateTimeOffset.UtcNow };
|
||||
_records[(record.TenantId.Value, record.ArtifactId)] = stored;
|
||||
return Task.FromResult(stored);
|
||||
}
|
||||
|
||||
public Task<EvidenceGateArtifactRecord?> GetByArtifactIdAsync(TenantId tenantId, string artifactId, CancellationToken cancellationToken)
|
||||
{
|
||||
_records.TryGetValue((tenantId.Value, artifactId), out var record);
|
||||
return Task.FromResult(record);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EvidenceLockerTestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
internal const string SchemeName = "EvidenceLockerTest";
|
||||
|
||||
@@ -83,6 +83,139 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
|
||||
Assert.Equal(snapshot.Signature.TimestampToken, bundle.Signature.TimestampToken);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GateArtifact_StoresAndRetrievesEvidenceScore()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
const string artifactId = "stella://svc/payments@sha256:abcd";
|
||||
|
||||
var request = CreateGateArtifactPayload(
|
||||
artifactId,
|
||||
attestationRefs:
|
||||
[
|
||||
"blob://attest/tests/playwright-2026-02-09.json",
|
||||
"blob://attest/provenance/att-123.json"
|
||||
]);
|
||||
|
||||
var storeResponse = await _client.PostAsJsonAsync("/evidence", request, CancellationToken.None);
|
||||
storeResponse.EnsureSuccessStatusCode();
|
||||
var stored = await storeResponse.Content.ReadFromJsonAsync<EvidenceGateArtifactResponseDto>(CancellationToken.None);
|
||||
Assert.NotNull(stored);
|
||||
Assert.True(stored!.Stored);
|
||||
Assert.False(string.IsNullOrWhiteSpace(stored.EvidenceId));
|
||||
Assert.Equal(64, stored.EvidenceScore.Length);
|
||||
|
||||
var lookupResponse = await _client.GetAsync($"/evidence/score?artifact_id={Uri.EscapeDataString(artifactId)}", CancellationToken.None);
|
||||
lookupResponse.EnsureSuccessStatusCode();
|
||||
var lookup = await lookupResponse.Content.ReadFromJsonAsync<EvidenceScoreResponseDto>(CancellationToken.None);
|
||||
Assert.NotNull(lookup);
|
||||
Assert.Equal("ready", lookup!.Status);
|
||||
Assert.Equal(stored.EvidenceScore, lookup.EvidenceScore);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GateArtifact_EvidenceScore_IsDeterministicAcrossAttestationOrdering()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
const string artifactId = "stella://svc/invoice@sha256:ef01";
|
||||
|
||||
var firstRequest = CreateGateArtifactPayload(
|
||||
artifactId,
|
||||
attestationRefs:
|
||||
[
|
||||
"sha256://b-attestation",
|
||||
"sha256://a-attestation"
|
||||
]);
|
||||
var secondRequest = CreateGateArtifactPayload(
|
||||
artifactId,
|
||||
attestationRefs:
|
||||
[
|
||||
"sha256://a-attestation",
|
||||
"sha256://b-attestation"
|
||||
]);
|
||||
|
||||
var firstResponse = await _client.PostAsJsonAsync("/evidence", firstRequest, CancellationToken.None);
|
||||
firstResponse.EnsureSuccessStatusCode();
|
||||
var first = await firstResponse.Content.ReadFromJsonAsync<EvidenceGateArtifactResponseDto>(CancellationToken.None);
|
||||
|
||||
var secondResponse = await _client.PostAsJsonAsync("/evidence", secondRequest, CancellationToken.None);
|
||||
secondResponse.EnsureSuccessStatusCode();
|
||||
var second = await secondResponse.Content.ReadFromJsonAsync<EvidenceGateArtifactResponseDto>(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(first);
|
||||
Assert.NotNull(second);
|
||||
Assert.Equal(first!.EvidenceScore, second!.EvidenceScore);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GateArtifact_InvalidDigest_ReturnsBadRequest()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
var invalidRequest = CreateGateArtifactPayload(
|
||||
artifactId: "stella://svc/catalog@sha256:1234",
|
||||
canonicalBomSha256: "abc",
|
||||
payloadDigest: new string('b', 64),
|
||||
attestationRefs: ["sha256://a"]);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/evidence", invalidRequest, CancellationToken.None);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(CancellationToken.None);
|
||||
Assert.NotNull(problem);
|
||||
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
|
||||
Assert.Contains(messages, message => message.Contains("canonical_bom_sha256", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GateArtifact_ScoreLookup_MissingArtifact_ReturnsNotFound()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceRead);
|
||||
|
||||
var response = await _client.GetAsync(
|
||||
"/evidence/score?artifact_id=stella%3A%2F%2Fsvc%2Fmissing%40sha256%3A1234",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
var problem = await response.Content.ReadFromJsonAsync<ErrorResponse>(CancellationToken.None);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("not_found", problem!.Code);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GateArtifact_AttestationRefWhitespace_ReturnsBadRequest()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
ConfigureAuthHeaders(_client, tenantId, scopes: StellaOpsScopes.EvidenceCreate);
|
||||
|
||||
var request = CreateGateArtifactPayload(
|
||||
artifactId: "stella://svc/catalog@sha256:1234",
|
||||
canonicalBomSha256: new string('a', 64),
|
||||
payloadDigest: new string('b', 64),
|
||||
attestationRefs:
|
||||
[
|
||||
"sha256://good",
|
||||
" "
|
||||
]);
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/evidence", request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>(CancellationToken.None);
|
||||
Assert.NotNull(problem);
|
||||
Assert.True(problem!.Errors.TryGetValue("message", out var messages));
|
||||
Assert.Contains(messages, message => message.Contains("attestation_refs[1]", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Snapshot_WithIncidentModeActive_ExtendsRetentionAndCapturesDebugArtifact()
|
||||
@@ -331,6 +464,36 @@ public sealed class EvidenceLockerWebServiceTests : IDisposable
|
||||
Assert.Contains($"hold:{hold!.CaseId}", _factory.TimelinePublisher.PublishedEvents);
|
||||
}
|
||||
|
||||
private static object CreateGateArtifactPayload(
|
||||
string artifactId,
|
||||
string? canonicalBomSha256 = null,
|
||||
string? payloadDigest = null,
|
||||
IReadOnlyList<string>? attestationRefs = null)
|
||||
{
|
||||
return new
|
||||
{
|
||||
producer_bundle = new
|
||||
{
|
||||
artifact_id = artifactId,
|
||||
canonical_bom_sha256 = canonicalBomSha256 ?? new string('a', 64),
|
||||
dsse_envelope_path = "blob://evidence/dsse/2026/02/09/abc.json",
|
||||
payload_digest = payloadDigest ?? new string('a', 64),
|
||||
rekor = new
|
||||
{
|
||||
index = 1234567,
|
||||
tile_id = "v2/tiles/0001/002a",
|
||||
inclusion_proof_path = "blob://evidence/proof/2026/02/09/abc.json"
|
||||
},
|
||||
attestation_refs = attestationRefs ?? ["blob://attest/default/att.json"]
|
||||
},
|
||||
raw_bom_path = "blob://evidence/raw_bom/2026/02/09/payments.json",
|
||||
vex_refs = new[]
|
||||
{
|
||||
"blob://evidence/vex/2026/02/09/payments.vex.json"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadArchiveEntries(byte[] archiveBytes)
|
||||
{
|
||||
using var memory = new MemoryStream(archiveBytes);
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0290-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0290-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0290-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| EL-GATE-TESTS | DONE | Added gate artifact endpoint/service determinism tests and migration assertion updates (2026-02-09). |
|
||||
|
||||
@@ -20,6 +20,8 @@ internal static class EvidenceAuditLogger
|
||||
private const string OperationHoldCreate = "hold.create";
|
||||
private const string OperationBundleDownload = "bundle.download";
|
||||
private const string OperationBundlePortable = "bundle.portable";
|
||||
private const string OperationGateArtifactStore = "gate-artifact.store";
|
||||
private const string OperationGateArtifactRead = "gate-artifact.read";
|
||||
|
||||
public static void LogTenantMissing(ILogger logger, ClaimsPrincipal user, string path)
|
||||
{
|
||||
@@ -286,6 +288,80 @@ internal static class EvidenceAuditLogger
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogGateArtifactStored(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string artifactId,
|
||||
string evidenceId,
|
||||
string evidenceScore)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} artifactId={ArtifactId} evidenceId={EvidenceId} evidenceScore={EvidenceScore} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationGateArtifactStore,
|
||||
tenantId.Value,
|
||||
artifactId,
|
||||
evidenceId,
|
||||
evidenceScore,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogGateArtifactRetrieved(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string artifactId,
|
||||
string evidenceScore)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogInformation(
|
||||
"Evidence audit operation={Operation} outcome=success tenant={TenantId} artifactId={ArtifactId} evidenceScore={EvidenceScore} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationGateArtifactRead,
|
||||
tenantId.Value,
|
||||
artifactId,
|
||||
evidenceScore,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogGateArtifactNotFound(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string artifactId)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=not_found tenant={TenantId} artifactId={ArtifactId} subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationGateArtifactRead,
|
||||
tenantId.Value,
|
||||
artifactId,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
public static void LogGateArtifactValidationFailure(
|
||||
ILogger logger,
|
||||
ClaimsPrincipal user,
|
||||
TenantId tenantId,
|
||||
string reason)
|
||||
{
|
||||
var identity = ExtractIdentity(user);
|
||||
logger.LogWarning(
|
||||
"Evidence audit operation={Operation} outcome=validation_failed tenant={TenantId} reason=\"{Reason}\" subject={Subject} clientId={ClientId} scopes={Scopes}",
|
||||
OperationGateArtifactStore,
|
||||
tenantId.Value,
|
||||
reason,
|
||||
identity.Subject,
|
||||
identity.ClientId,
|
||||
identity.Scopes);
|
||||
}
|
||||
|
||||
private static AuditIdentity ExtractIdentity(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user is null)
|
||||
|
||||
@@ -7,6 +7,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.WebService.Contracts;
|
||||
|
||||
@@ -101,6 +102,68 @@ public sealed record EvidenceHoldResponseDto(
|
||||
DateTimeOffset? ReleasedAt,
|
||||
string? Notes);
|
||||
|
||||
public sealed record EvidenceGateArtifactRequestDto
|
||||
{
|
||||
[Required]
|
||||
[JsonPropertyName("producer_bundle")]
|
||||
public EvidenceProducerBundleDto ProducerBundle { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("raw_bom_path")]
|
||||
public string? RawBomPath { get; init; }
|
||||
|
||||
[JsonPropertyName("vex_refs")]
|
||||
public List<string> VexRefs { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record EvidenceProducerBundleDto
|
||||
{
|
||||
[Required]
|
||||
[JsonPropertyName("artifact_id")]
|
||||
public string ArtifactId { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("canonical_bom_sha256")]
|
||||
public string CanonicalBomSha256 { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("dsse_envelope_path")]
|
||||
public string DsseEnvelopePath { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("payload_digest")]
|
||||
public string PayloadDigest { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("rekor")]
|
||||
public EvidenceRekorRefDto Rekor { get; init; } = new();
|
||||
|
||||
[JsonPropertyName("attestation_refs")]
|
||||
public List<string> AttestationRefs { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed record EvidenceRekorRefDto
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public long Index { get; init; }
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("tile_id")]
|
||||
public string TileId { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[JsonPropertyName("inclusion_proof_path")]
|
||||
public string InclusionProofPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record EvidenceGateArtifactResponseDto(
|
||||
[property: JsonPropertyName("evidence_id")] string EvidenceId,
|
||||
[property: JsonPropertyName("evidence_score")] string EvidenceScore,
|
||||
[property: JsonPropertyName("stored")] bool Stored);
|
||||
|
||||
public sealed record EvidenceScoreResponseDto(
|
||||
[property: JsonPropertyName("evidence_score")] string EvidenceScore,
|
||||
[property: JsonPropertyName("status")] string Status);
|
||||
|
||||
public sealed record ErrorResponse(string Code, string Message);
|
||||
|
||||
public static class EvidenceContractMapper
|
||||
@@ -133,6 +196,24 @@ public static class EvidenceContractMapper
|
||||
Notes = dto.Notes
|
||||
};
|
||||
|
||||
public static EvidenceGateArtifactSubmission ToDomain(this EvidenceGateArtifactRequestDto dto)
|
||||
=> new()
|
||||
{
|
||||
ProducerBundle = new EvidenceProducerBundle
|
||||
{
|
||||
ArtifactId = dto.ProducerBundle.ArtifactId,
|
||||
CanonicalBomSha256 = dto.ProducerBundle.CanonicalBomSha256,
|
||||
DsseEnvelopeRef = dto.ProducerBundle.DsseEnvelopePath,
|
||||
PayloadDigest = dto.ProducerBundle.PayloadDigest,
|
||||
RekorIndex = dto.ProducerBundle.Rekor.Index,
|
||||
RekorTileId = dto.ProducerBundle.Rekor.TileId,
|
||||
RekorInclusionProofRef = dto.ProducerBundle.Rekor.InclusionProofPath,
|
||||
AttestationRefs = dto.ProducerBundle.AttestationRefs
|
||||
},
|
||||
RawBomRef = dto.RawBomPath,
|
||||
VexRefs = dto.VexRefs
|
||||
};
|
||||
|
||||
public static EvidenceBundleSignatureDto? ToDto(this EvidenceBundleSignature? signature)
|
||||
{
|
||||
if (signature is null)
|
||||
|
||||
@@ -71,6 +71,77 @@ app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
app.MapPost("/evidence",
|
||||
async (HttpContext context, ClaimsPrincipal user, EvidenceGateArtifactRequestDto request, EvidenceGateArtifactService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.StoreAsync(tenantId, request.ToDomain(), cancellationToken);
|
||||
EvidenceAuditLogger.LogGateArtifactStored(logger, user, tenantId, request.ProducerBundle.ArtifactId, result.EvidenceId, result.EvidenceScore);
|
||||
return Results.Created(
|
||||
$"/evidence/score?artifact_id={Uri.EscapeDataString(request.ProducerBundle.ArtifactId)}",
|
||||
new EvidenceGateArtifactResponseDto(result.EvidenceId, result.EvidenceScore, result.Stored));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
EvidenceAuditLogger.LogGateArtifactValidationFailure(logger, user, tenantId, ex.Message);
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate)
|
||||
.Produces<EvidenceGateArtifactResponseDto>(StatusCodes.Status201Created)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.WithName("StoreEvidenceGateArtifact")
|
||||
.WithTags("Evidence")
|
||||
.WithSummary("Ingest producer gate artifact evidence and compute deterministic evidence score.");
|
||||
|
||||
app.MapGet("/evidence/score",
|
||||
async (HttpContext context, ClaimsPrincipal user, string artifact_id, EvidenceGateArtifactService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
|
||||
|
||||
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
|
||||
{
|
||||
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/score");
|
||||
return ForbidTenant();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.GetScoreAsync(tenantId, artifact_id, cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
EvidenceAuditLogger.LogGateArtifactNotFound(logger, user, tenantId, artifact_id);
|
||||
return Results.NotFound(new ErrorResponse("not_found", "Evidence score not found for artifact."));
|
||||
}
|
||||
|
||||
EvidenceAuditLogger.LogGateArtifactRetrieved(logger, user, tenantId, result.ArtifactId, result.EvidenceScore);
|
||||
return Results.Ok(new EvidenceScoreResponseDto(result.EvidenceScore, result.Status));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
EvidenceAuditLogger.LogGateArtifactValidationFailure(logger, user, tenantId, ex.Message);
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
|
||||
.Produces<EvidenceScoreResponseDto>(StatusCodes.Status200OK)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
|
||||
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
|
||||
.WithName("GetEvidenceScore")
|
||||
.WithTags("Evidence")
|
||||
.WithSummary("Get deterministic evidence score by artifact identifier.");
|
||||
|
||||
app.MapPost("/evidence/snapshot",
|
||||
async (HttpContext context, ClaimsPrincipal user, EvidenceSnapshotRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
|
||||
{
|
||||
|
||||
@@ -8,3 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0291-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0291-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0291-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| EL-GATE-001 | DONE | Added `/evidence` ingestion + `/evidence/score` lookup contracts with fail-closed validation and audit events (2026-02-09). |
|
||||
|
||||
Reference in New Issue
Block a user