using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Infrastructure; using StellaOps.Findings.Ledger.Infrastructure.Attestation; using StellaOps.Findings.Ledger.Observability; namespace StellaOps.Findings.Ledger.Services; /// /// Service for managing attestation pointers linking findings to verification reports and attestation envelopes. /// public sealed class AttestationPointerService { private readonly ILedgerEventRepository _ledgerEventRepository; private readonly ILedgerEventWriteService _writeService; private readonly IAttestationPointerRepository _repository; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public AttestationPointerService( ILedgerEventRepository ledgerEventRepository, ILedgerEventWriteService writeService, IAttestationPointerRepository repository, TimeProvider timeProvider, ILogger logger) { _ledgerEventRepository = ledgerEventRepository ?? throw new ArgumentNullException(nameof(ledgerEventRepository)); _writeService = writeService ?? throw new ArgumentNullException(nameof(writeService)); _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Creates an attestation pointer linking a finding to a verification report or attestation envelope. /// public async Task CreatePointerAsync( AttestationPointerInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); ArgumentException.ThrowIfNullOrWhiteSpace(input.TenantId); ArgumentException.ThrowIfNullOrWhiteSpace(input.FindingId); ArgumentException.ThrowIfNullOrWhiteSpace(input.AttestationRef.Digest); var now = _timeProvider.GetUtcNow(); var createdBy = input.CreatedBy ?? "attestation-linker"; // Check for idempotency var exists = await _repository.ExistsAsync( input.TenantId, input.FindingId, input.AttestationRef.Digest, input.AttestationType, cancellationToken).ConfigureAwait(false); if (exists) { _logger.LogDebug( "Attestation pointer already exists for finding {FindingId} with digest {Digest}", input.FindingId, input.AttestationRef.Digest); // Find and return the existing pointer var existing = await _repository.GetByDigestAsync( input.TenantId, input.AttestationRef.Digest, cancellationToken).ConfigureAwait(false); var match = existing.FirstOrDefault(p => p.FindingId == input.FindingId && p.AttestationType == input.AttestationType); return new AttestationPointerResult(true, match?.PointerId, match?.LedgerEventId, null); } var pointerId = Guid.NewGuid(); // Create ledger event for the attestation pointer var chainId = LedgerChainIdGenerator.FromTenantSubject( input.TenantId, $"attestation::{input.FindingId}"); var chainHead = await _ledgerEventRepository.GetChainHeadAsync( input.TenantId, chainId, cancellationToken).ConfigureAwait(false); var sequence = (chainHead?.SequenceNumber ?? 0) + 1; var previousHash = chainHead?.EventHash ?? LedgerEventConstants.EmptyHash; var eventId = Guid.NewGuid(); var attestationPayload = BuildAttestationPayload(input, pointerId); var envelope = BuildEnvelope(eventId, input, chainId, sequence, now, attestationPayload); var draft = new LedgerEventDraft( input.TenantId, chainId, sequence, eventId, LedgerEventConstants.EventAttestationPointerLinked, "attestation-pointer", input.FindingId, input.FindingId, SourceRunId: null, ActorId: createdBy, ActorType: "system", OccurredAt: now, RecordedAt: now, Payload: attestationPayload, CanonicalEnvelope: envelope, ProvidedPreviousHash: previousHash); var writeResult = await _writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false); if (writeResult.Status is not (LedgerWriteStatus.Success or LedgerWriteStatus.Idempotent)) { var error = string.Join(";", writeResult.Errors); _logger.LogWarning( "Failed to write ledger event for attestation pointer {PointerId}: {Error}", pointerId, error); return new AttestationPointerResult(false, null, null, error); } var ledgerEventId = writeResult.Record?.EventId; var record = new AttestationPointerRecord( input.TenantId, pointerId, input.FindingId, input.AttestationType, input.Relationship, input.AttestationRef, input.VerificationResult, now, createdBy, input.Metadata, ledgerEventId); await _repository.InsertAsync(record, cancellationToken).ConfigureAwait(false); LedgerTimeline.EmitAttestationPointerLinked( _logger, input.TenantId, input.FindingId, pointerId, input.AttestationType.ToString(), input.AttestationRef.Digest); return new AttestationPointerResult(true, pointerId, ledgerEventId, null); } /// /// Gets attestation pointers for a finding. /// public async Task> GetPointersAsync( string tenantId, string findingId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(findingId); return await _repository.GetByFindingIdAsync(tenantId, findingId, cancellationToken) .ConfigureAwait(false); } /// /// Gets an attestation pointer by ID. /// public async Task GetPointerAsync( string tenantId, Guid pointerId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); return await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken) .ConfigureAwait(false); } /// /// Searches attestation pointers. /// public async Task> SearchAsync( AttestationPointerQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); return await _repository.SearchAsync(query, cancellationToken).ConfigureAwait(false); } /// /// Gets attestation summary for a finding. /// public async Task GetSummaryAsync( string tenantId, string findingId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(findingId); return await _repository.GetSummaryAsync(tenantId, findingId, cancellationToken) .ConfigureAwait(false); } /// /// Gets attestation summaries for multiple findings. /// public async Task> GetSummariesAsync( string tenantId, IReadOnlyList findingIds, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNull(findingIds); return await _repository.GetSummariesAsync(tenantId, findingIds, cancellationToken) .ConfigureAwait(false); } /// /// Updates the verification result for an attestation pointer. /// public async Task UpdateVerificationResultAsync( string tenantId, Guid pointerId, VerificationResult verificationResult, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNull(verificationResult); var existing = await _repository.GetByIdAsync(tenantId, pointerId, cancellationToken) .ConfigureAwait(false); if (existing is null) { _logger.LogWarning( "Attestation pointer {PointerId} not found for tenant {TenantId}", pointerId, tenantId); return false; } await _repository.UpdateVerificationResultAsync( tenantId, pointerId, verificationResult, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Updated verification result for attestation pointer {PointerId}, verified={Verified}", pointerId, verificationResult.Verified); return true; } /// /// Gets findings that have attestations matching the criteria. /// public async Task> GetFindingIdsWithAttestationsAsync( string tenantId, AttestationVerificationFilter? verificationFilter = null, IReadOnlyList? attestationTypes = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); return await _repository.GetFindingIdsWithAttestationsAsync( tenantId, verificationFilter, attestationTypes, limit, offset, cancellationToken) .ConfigureAwait(false); } private static JsonObject BuildAttestationPayload(AttestationPointerInput input, Guid pointerId) { var attestationRefNode = new JsonObject { ["digest"] = input.AttestationRef.Digest }; if (input.AttestationRef.AttestationId.HasValue) { attestationRefNode["attestation_id"] = input.AttestationRef.AttestationId.Value.ToString(); } if (!string.IsNullOrEmpty(input.AttestationRef.StorageUri)) { attestationRefNode["storage_uri"] = input.AttestationRef.StorageUri; } if (!string.IsNullOrEmpty(input.AttestationRef.PayloadType)) { attestationRefNode["payload_type"] = input.AttestationRef.PayloadType; } if (!string.IsNullOrEmpty(input.AttestationRef.PredicateType)) { attestationRefNode["predicate_type"] = input.AttestationRef.PredicateType; } if (input.AttestationRef.SubjectDigests is { Count: > 0 }) { var subjectsArray = new JsonArray(); foreach (var subject in input.AttestationRef.SubjectDigests) { subjectsArray.Add(subject); } attestationRefNode["subject_digests"] = subjectsArray; } if (input.AttestationRef.SignerInfo is not null) { var signerNode = new JsonObject(); if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.KeyId)) { signerNode["key_id"] = input.AttestationRef.SignerInfo.KeyId; } if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Issuer)) { signerNode["issuer"] = input.AttestationRef.SignerInfo.Issuer; } if (!string.IsNullOrEmpty(input.AttestationRef.SignerInfo.Subject)) { signerNode["subject"] = input.AttestationRef.SignerInfo.Subject; } if (input.AttestationRef.SignerInfo.SignedAt.HasValue) { signerNode["signed_at"] = FormatTimestamp(input.AttestationRef.SignerInfo.SignedAt.Value); } attestationRefNode["signer_info"] = signerNode; } if (input.AttestationRef.RekorEntry is not null) { var rekorNode = new JsonObject(); if (input.AttestationRef.RekorEntry.LogIndex.HasValue) { rekorNode["log_index"] = input.AttestationRef.RekorEntry.LogIndex.Value; } if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.LogId)) { rekorNode["log_id"] = input.AttestationRef.RekorEntry.LogId; } if (!string.IsNullOrEmpty(input.AttestationRef.RekorEntry.Uuid)) { rekorNode["uuid"] = input.AttestationRef.RekorEntry.Uuid; } if (input.AttestationRef.RekorEntry.IntegratedTime.HasValue) { rekorNode["integrated_time"] = input.AttestationRef.RekorEntry.IntegratedTime.Value; } attestationRefNode["rekor_entry"] = rekorNode; } var pointerNode = new JsonObject { ["pointer_id"] = pointerId.ToString(), ["attestation_type"] = input.AttestationType.ToString(), ["relationship"] = input.Relationship.ToString(), ["attestation_ref"] = attestationRefNode }; if (input.VerificationResult is not null) { var verificationNode = new JsonObject { ["verified"] = input.VerificationResult.Verified, ["verified_at"] = FormatTimestamp(input.VerificationResult.VerifiedAt) }; if (!string.IsNullOrEmpty(input.VerificationResult.Verifier)) { verificationNode["verifier"] = input.VerificationResult.Verifier; } if (!string.IsNullOrEmpty(input.VerificationResult.VerifierVersion)) { verificationNode["verifier_version"] = input.VerificationResult.VerifierVersion; } if (!string.IsNullOrEmpty(input.VerificationResult.PolicyRef)) { verificationNode["policy_ref"] = input.VerificationResult.PolicyRef; } if (input.VerificationResult.Checks is { Count: > 0 }) { var checksArray = new JsonArray(); foreach (var check in input.VerificationResult.Checks) { var checkNode = new JsonObject { ["check_type"] = check.CheckType.ToString(), ["passed"] = check.Passed }; if (!string.IsNullOrEmpty(check.Details)) { checkNode["details"] = check.Details; } checksArray.Add(checkNode); } verificationNode["checks"] = checksArray; } if (input.VerificationResult.Warnings is { Count: > 0 }) { var warningsArray = new JsonArray(); foreach (var warning in input.VerificationResult.Warnings) { warningsArray.Add(warning); } verificationNode["warnings"] = warningsArray; } if (input.VerificationResult.Errors is { Count: > 0 }) { var errorsArray = new JsonArray(); foreach (var error in input.VerificationResult.Errors) { errorsArray.Add(error); } verificationNode["errors"] = errorsArray; } pointerNode["verification_result"] = verificationNode; } return new JsonObject { ["attestation"] = new JsonObject { ["pointer"] = pointerNode } }; } private static JsonObject BuildEnvelope( Guid eventId, AttestationPointerInput input, Guid chainId, long sequence, DateTimeOffset now, JsonObject payload) { return new JsonObject { ["event"] = new JsonObject { ["id"] = eventId.ToString(), ["type"] = LedgerEventConstants.EventAttestationPointerLinked, ["tenant"] = input.TenantId, ["chainId"] = chainId.ToString(), ["sequence"] = sequence, ["policyVersion"] = "attestation-pointer", ["artifactId"] = input.FindingId, ["finding"] = new JsonObject { ["id"] = input.FindingId, ["artifactId"] = input.FindingId, ["vulnId"] = "attestation-pointer" }, ["actor"] = new JsonObject { ["id"] = input.CreatedBy ?? "attestation-linker", ["type"] = "system" }, ["occurredAt"] = FormatTimestamp(now), ["recordedAt"] = FormatTimestamp(now), ["payload"] = payload.DeepClone() } }; } private static string FormatTimestamp(DateTimeOffset value) => value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"); }