using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace StellaOps.Policy; public sealed class PolicySnapshotStore { private readonly IPolicySnapshotRepository _snapshotRepository; private readonly IPolicyAuditRepository _auditRepository; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private readonly SemaphoreSlim _mutex = new(1, 1); public PolicySnapshotStore( IPolicySnapshotRepository snapshotRepository, IPolicyAuditRepository auditRepository, TimeProvider? timeProvider, ILogger logger) { _snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository)); _auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository)); _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default) { if (content is null) { throw new ArgumentNullException(nameof(content)); } var bindingResult = PolicyBinder.Bind(content.Content, content.Format); if (!bindingResult.Success) { _logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format); return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult); } var digest = PolicyDigest.Compute(bindingResult.Document); await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false); try { var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false); if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal)) { _logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId); return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult); } var revisionNumber = (latest?.RevisionNumber ?? 0) + 1; var revisionId = $"rev-{revisionNumber}"; var createdAt = _timeProvider.GetUtcNow(); var scoringConfig = PolicyScoringConfig.Default; var snapshot = new PolicySnapshot( revisionNumber, revisionId, digest, createdAt, content.Actor, content.Format, bindingResult.Document, bindingResult.Issues, scoringConfig); await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false); var auditMessage = content.Description ?? "Policy snapshot created"; var auditEntry = new PolicyAuditEntry( Guid.NewGuid(), createdAt, "snapshot.created", revisionId, digest, content.Actor, auditMessage); await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}", revisionId, digest, bindingResult.Issues.Length); return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult); } finally { _mutex.Release(); } } public Task GetLatestAsync(CancellationToken cancellationToken = default) => _snapshotRepository.GetLatestAsync(cancellationToken); }