// ----------------------------------------------------------------------------- // HumanApprovalAttestationService.cs // Sprint: SPRINT_3801_0001_0004_human_approval_attestation (APPROVE-003) // Description: Creates DSSE attestations for human approval decisions. // ----------------------------------------------------------------------------- using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; namespace StellaOps.Scanner.WebService.Services; /// /// Creates DSSE attestations for human approval decisions. /// public sealed class HumanApprovalAttestationService : IHumanApprovalAttestationService { private readonly ILogger _logger; private readonly HumanApprovalAttestationOptions _options; private readonly TimeProvider _timeProvider; /// /// In-memory attestation store. In production, this would be backed by a database. /// Key format: "{scanId}:{findingId}" /// private readonly ConcurrentDictionary _attestations = new(); /// /// Initializes a new instance of . /// public HumanApprovalAttestationService( ILogger logger, IOptions options, TimeProvider timeProvider) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); } /// public Task CreateAttestationAsync( HumanApprovalAttestationInput input, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(input); if (string.IsNullOrWhiteSpace(input.FindingId)) { throw new ArgumentException("FindingId is required", nameof(input)); } if (string.IsNullOrWhiteSpace(input.ApproverUserId)) { throw new ArgumentException("ApproverUserId is required", nameof(input)); } if (string.IsNullOrWhiteSpace(input.Justification)) { throw new ArgumentException("Justification is required", nameof(input)); } _logger.LogDebug( "Creating human approval attestation for finding {FindingId}, decision {Decision}", input.FindingId, input.Decision); var now = _timeProvider.GetUtcNow(); var ttl = input.ApprovalTtl ?? TimeSpan.FromDays(_options.DefaultApprovalTtlDays); var expiresAt = now.Add(ttl); var approvalId = $"approval-{Guid.NewGuid():N}"; var statement = BuildStatement(input, approvalId, now, expiresAt); var attestationId = ComputeAttestationId(statement); // Store the attestation var key = BuildKey(input.ScanId, input.FindingId); var storedApproval = new StoredApproval { Result = HumanApprovalAttestationResult.Succeeded(statement, attestationId), IsRevoked = false, RevokedAt = null, RevokedBy = null, RevocationReason = null }; _attestations.AddOrUpdate(key, storedApproval, (_, _) => storedApproval); _logger.LogInformation( "Created human approval attestation {AttestationId} for finding {FindingId}, expires {ExpiresAt}", attestationId, input.FindingId, expiresAt); return Task.FromResult(storedApproval.Result); } /// public Task GetAttestationAsync( ScanId scanId, string findingId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(scanId); if (string.IsNullOrWhiteSpace(findingId)) { return Task.FromResult(null); } var key = BuildKey(scanId, findingId); if (_attestations.TryGetValue(key, out var stored)) { // Check if expired var now = _timeProvider.GetUtcNow(); if (stored.Result.Statement?.Predicate.ExpiresAt < now) { _logger.LogDebug( "Approval attestation for finding {FindingId} has expired", findingId); return Task.FromResult(null); } if (stored.IsRevoked) { return Task.FromResult( stored.Result with { IsRevoked = true }); } return Task.FromResult(stored.Result); } return Task.FromResult(null); } /// public Task> GetApprovalsByScanAsync( ScanId scanId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(scanId); var prefix = $"{scanId}:"; var now = _timeProvider.GetUtcNow(); var results = _attestations .Where(kvp => kvp.Key.StartsWith(prefix, StringComparison.Ordinal)) .Where(kvp => !kvp.Value.IsRevoked) .Where(kvp => kvp.Value.Result.Statement?.Predicate.ExpiresAt >= now) .Select(kvp => kvp.Value.Result) .ToList(); return Task.FromResult>(results); } /// public Task RevokeApprovalAsync( ScanId scanId, string findingId, string revokedBy, string reason, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(scanId); if (string.IsNullOrWhiteSpace(findingId)) { return Task.FromResult(false); } if (string.IsNullOrWhiteSpace(revokedBy)) { throw new ArgumentException("revokedBy is required", nameof(revokedBy)); } var key = BuildKey(scanId, findingId); if (_attestations.TryGetValue(key, out var stored)) { var revoked = stored with { IsRevoked = true, RevokedAt = _timeProvider.GetUtcNow(), RevokedBy = revokedBy, RevocationReason = reason }; _attestations.TryUpdate(key, revoked, stored); _logger.LogInformation( "Revoked approval attestation for finding {FindingId} by {RevokedBy}: {Reason}", findingId, revokedBy, reason); return Task.FromResult(true); } return Task.FromResult(false); } private HumanApprovalStatement BuildStatement( HumanApprovalAttestationInput input, string approvalId, DateTimeOffset approvedAt, DateTimeOffset expiresAt) { var scanDigest = ComputeSha256(input.ScanId.ToString()); var findingDigest = ComputeSha256(input.FindingId); return new HumanApprovalStatement { Subject = new List { new() { Name = $"scan:{input.ScanId}", Digest = new Dictionary { ["sha256"] = scanDigest } }, new() { Name = $"finding:{input.FindingId}", Digest = new Dictionary { ["sha256"] = findingDigest } } }, Predicate = new HumanApprovalPredicate { ApprovalId = approvalId, FindingId = input.FindingId, Decision = input.Decision, Approver = new ApproverInfo { UserId = input.ApproverUserId, DisplayName = input.ApproverDisplayName, Role = input.ApproverRole }, Justification = input.Justification, ApprovedAt = approvedAt, ExpiresAt = expiresAt, PolicyDecisionRef = input.PolicyDecisionRef, Restrictions = input.Restrictions, Supersedes = input.Supersedes, Metadata = input.Metadata } }; } private static string ComputeAttestationId(HumanApprovalStatement statement) { var json = JsonSerializer.Serialize(statement); return ComputeSha256(json); } private static string ComputeSha256(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hash = SHA256.HashData(bytes); return $"sha256:{Convert.ToHexStringLower(hash)}"; } private static string BuildKey(ScanId scanId, string findingId) => $"{scanId}:{findingId}"; /// /// Internal storage for approval attestations with revocation tracking. /// private sealed record StoredApproval { public required HumanApprovalAttestationResult Result { get; init; } public bool IsRevoked { get; init; } public DateTimeOffset? RevokedAt { get; init; } public string? RevokedBy { get; init; } public string? RevocationReason { get; init; } } } /// /// Options for human approval attestation service. /// public sealed class HumanApprovalAttestationOptions { /// /// Default TTL for approvals in days (default: 30). /// public int DefaultApprovalTtlDays { get; set; } = 30; /// /// Whether to enable DSSE signing. /// public bool EnableSigning { get; set; } = true; /// /// Minimum justification length required. /// public int MinJustificationLength { get; set; } = 10; /// /// Roles authorized to approve high-severity findings. /// public IList HighSeverityApproverRoles { get; set; } = new List { "security_lead", "ciso", "security_architect" }; }