// -----------------------------------------------------------------------------
// 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"
};
}