save checkpoint. addition features and their state. check some ofthem

This commit is contained in:
master
2026-02-10 07:54:44 +02:00
parent 4bdc298ec1
commit 5593212b41
211 changed files with 10248 additions and 1208 deletions

View File

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

View File

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

View File

@@ -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). |

View File

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

View File

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

View File

@@ -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) ?? [];
}
}

View File

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

View File

@@ -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). |

View File

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

View File

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

View File

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

View File

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

View File

@@ -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). |

View File

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

View File

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

View File

@@ -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) =>
{

View File

@@ -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). |