warnings fixes, tests fixes, sprints completions

This commit is contained in:
Codex Assistant
2026-01-08 08:38:27 +02:00
parent 75611a505f
commit 0b5d786ddb
125 changed files with 14610 additions and 368 deletions

View File

@@ -20,10 +20,12 @@ public sealed class RvaBuilder
private DateTimeOffset? _expiresAt;
private readonly Dictionary<string, string> _metadata = [];
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
public RvaBuilder(ICryptoHash cryptoHash)
public RvaBuilder(ICryptoHash cryptoHash, TimeProvider timeProvider)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public RvaBuilder WithVerdict(RiskVerdictStatus verdict)
@@ -162,7 +164,7 @@ public sealed class RvaBuilder
if (_snapshotId is null)
throw new InvalidOperationException("Knowledge snapshot ID is required");
var createdAt = DateTimeOffset.UtcNow;
var createdAt = _timeProvider.GetUtcNow();
var attestation = new RiskVerdictAttestation
{

View File

@@ -16,14 +16,17 @@ public sealed class RvaVerifier : IRvaVerifier
private readonly ICryptoSigner? _signer;
private readonly ISnapshotService _snapshotService;
private readonly ILogger<RvaVerifier> _logger;
private readonly TimeProvider _timeProvider;
public RvaVerifier(
ISnapshotService snapshotService,
ILogger<RvaVerifier> logger,
TimeProvider timeProvider,
ICryptoSigner? signer = null)
{
_snapshotService = snapshotService ?? throw new ArgumentNullException(nameof(snapshotService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_signer = signer;
}
@@ -51,7 +54,7 @@ public sealed class RvaVerifier : IRvaVerifier
issues.Add($"Signature verification failed: {sigResult.Error}");
if (!options.ContinueOnSignatureFailure)
{
return RvaVerificationResult.Fail(issues);
return RvaVerificationResult.Fail(issues, _timeProvider);
}
}
}
@@ -61,7 +64,7 @@ public sealed class RvaVerifier : IRvaVerifier
if (attestation is null)
{
issues.Add("Failed to parse RVA payload");
return RvaVerificationResult.Fail(issues);
return RvaVerificationResult.Fail(issues, _timeProvider);
}
// Step 3: Verify content-addressed ID
@@ -69,18 +72,18 @@ public sealed class RvaVerifier : IRvaVerifier
if (!idValid)
{
issues.Add("Attestation ID does not match content");
return RvaVerificationResult.Fail(issues);
return RvaVerificationResult.Fail(issues, _timeProvider);
}
// Step 4: Verify expiration
if (options.CheckExpiration && attestation.ExpiresAt.HasValue)
{
if (attestation.ExpiresAt.Value < DateTimeOffset.UtcNow)
if (attestation.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
issues.Add($"Attestation expired at {attestation.ExpiresAt.Value:o}");
if (!options.AllowExpired)
{
return RvaVerificationResult.Fail(issues);
return RvaVerificationResult.Fail(issues, _timeProvider);
}
}
}
@@ -106,7 +109,7 @@ public sealed class RvaVerifier : IRvaVerifier
Attestation = attestation,
SignerIdentity = signerIdentity,
Issues = issues,
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
};
}
@@ -127,18 +130,18 @@ public sealed class RvaVerifier : IRvaVerifier
if (!idValid)
{
issues.Add("Attestation ID does not match content");
return Task.FromResult(RvaVerificationResult.Fail(issues));
return Task.FromResult(RvaVerificationResult.Fail(issues, _timeProvider));
}
// Verify expiration
if (options.CheckExpiration && attestation.ExpiresAt.HasValue)
{
if (attestation.ExpiresAt.Value < DateTimeOffset.UtcNow)
if (attestation.ExpiresAt.Value < _timeProvider.GetUtcNow())
{
issues.Add($"Attestation expired at {attestation.ExpiresAt.Value:o}");
if (!options.AllowExpired)
{
return Task.FromResult(RvaVerificationResult.Fail(issues));
return Task.FromResult(RvaVerificationResult.Fail(issues, _timeProvider));
}
}
}
@@ -152,7 +155,7 @@ public sealed class RvaVerifier : IRvaVerifier
Attestation = attestation,
SignerIdentity = null,
Issues = issues,
VerifiedAt = DateTimeOffset.UtcNow
VerifiedAt = _timeProvider.GetUtcNow()
});
}
@@ -291,10 +294,10 @@ public sealed record RvaVerificationResult
public RiskVerdictAttestation? Attestation { get; init; }
public string? SignerIdentity { get; init; }
public IReadOnlyList<string> Issues { get; init; } = [];
public DateTimeOffset VerifiedAt { get; init; }
public required DateTimeOffset VerifiedAt { get; init; }
public static RvaVerificationResult Fail(IReadOnlyList<string> issues) =>
new() { IsValid = false, Issues = issues, VerifiedAt = DateTimeOffset.UtcNow };
public static RvaVerificationResult Fail(IReadOnlyList<string> issues, TimeProvider timeProvider) =>
new() { IsValid = false, Issues = issues, VerifiedAt = timeProvider.GetUtcNow() };
}
/// <summary>

View File

@@ -143,13 +143,15 @@ public sealed record ScoreProvenanceChain
public static ScoreProvenanceChain FromVerdictPredicate(
VerdictPredicate predicate,
ProvenanceFindingRef finding,
ProvenanceEvidenceSet evidenceSet)
ProvenanceEvidenceSet evidenceSet,
TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(predicate);
ArgumentNullException.ThrowIfNull(finding);
ArgumentNullException.ThrowIfNull(evidenceSet);
ArgumentNullException.ThrowIfNull(timeProvider);
var scoreNode = ProvenanceScoreNode.FromVerdictEws(predicate.EvidenceWeightedScore, predicate.FindingId);
var scoreNode = ProvenanceScoreNode.FromVerdictEws(predicate.EvidenceWeightedScore, predicate.FindingId, timeProvider);
var verdictRef = ProvenanceVerdictRef.FromVerdictPredicate(predicate);
return new ScoreProvenanceChain(
@@ -157,7 +159,7 @@ public sealed record ScoreProvenanceChain
evidenceSet: evidenceSet,
score: scoreNode,
verdict: verdictRef,
createdAt: DateTimeOffset.UtcNow
createdAt: timeProvider.GetUtcNow()
);
}
}
@@ -533,8 +535,9 @@ public sealed record ProvenanceScoreNode
/// <summary>
/// Creates a ProvenanceScoreNode from a VerdictEvidenceWeightedScore.
/// </summary>
public static ProvenanceScoreNode FromVerdictEws(VerdictEvidenceWeightedScore? ews, string findingId)
public static ProvenanceScoreNode FromVerdictEws(VerdictEvidenceWeightedScore? ews, string findingId, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(timeProvider);
if (ews is null)
{
// No EWS - create a placeholder node
@@ -545,7 +548,7 @@ public sealed record ProvenanceScoreNode
weights: new VerdictEvidenceWeights(0, 0, 0, 0, 0, 0),
policyDigest: "none",
calculatorVersion: "none",
calculatedAt: DateTimeOffset.UtcNow
calculatedAt: timeProvider.GetUtcNow()
);
}
@@ -560,7 +563,7 @@ public sealed record ProvenanceScoreNode
weights: new VerdictEvidenceWeights(0, 0, 0, 0, 0, 0),
policyDigest: ews.PolicyDigest ?? "unknown",
calculatorVersion: "unknown",
calculatedAt: ews.CalculatedAt ?? DateTimeOffset.UtcNow,
calculatedAt: ews.CalculatedAt ?? timeProvider.GetUtcNow(),
appliedFlags: ews.Flags,
guardrails: ews.Guardrails
);

View File

@@ -12,7 +12,9 @@ public static class ExceptionMapper
/// <summary>
/// Maps an ExceptionObject to a full DTO.
/// </summary>
public static ExceptionDto ToDto(ExceptionObject exception)
/// <param name="exception">The exception to map.</param>
/// <param name="referenceTime">The reference time for IsEffective/HasExpired checks.</param>
public static ExceptionDto ToDto(ExceptionObject exception, DateTimeOffset referenceTime)
{
return new ExceptionDto
{
@@ -34,15 +36,17 @@ public static class ExceptionMapper
CompensatingControls = exception.CompensatingControls.ToList(),
Metadata = exception.Metadata,
TicketRef = exception.TicketRef,
IsEffective = exception.IsEffective,
HasExpired = exception.HasExpired
IsEffective = exception.IsEffectiveAt(referenceTime),
HasExpired = exception.HasExpiredAt(referenceTime)
};
}
/// <summary>
/// Maps an ExceptionObject to a summary DTO for list responses.
/// </summary>
public static ExceptionSummaryDto ToSummaryDto(ExceptionObject exception)
/// <param name="exception">The exception to map.</param>
/// <param name="referenceTime">The reference time for IsEffective check.</param>
public static ExceptionSummaryDto ToSummaryDto(ExceptionObject exception, DateTimeOffset referenceTime)
{
return new ExceptionSummaryDto
{
@@ -54,7 +58,7 @@ public static class ExceptionMapper
OwnerId = exception.OwnerId,
ExpiresAt = exception.ExpiresAt,
ReasonCode = ReasonToString(exception.ReasonCode),
IsEffective = exception.IsEffective
IsEffective = exception.IsEffectiveAt(referenceTime)
};
}

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
@@ -335,6 +336,8 @@ internal static class ViolationEndpoints
HttpContext context,
[FromBody] CreateViolationRequest request,
IViolationEventRepository repository,
TimeProvider timeProvider,
IGuidProvider guidProvider,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
@@ -356,7 +359,7 @@ internal static class ViolationEndpoints
var entity = new ViolationEventEntity
{
Id = Guid.NewGuid(),
Id = guidProvider.NewGuid(),
TenantId = tenantId,
PolicyId = request.PolicyId,
RuleId = request.RuleId,
@@ -366,7 +369,7 @@ internal static class ViolationEndpoints
Details = request.Details ?? "{}",
Remediation = request.Remediation,
CorrelationId = request.CorrelationId,
OccurredAt = request.OccurredAt ?? DateTimeOffset.UtcNow
OccurredAt = request.OccurredAt ?? timeProvider.GetUtcNow()
};
try
@@ -389,6 +392,8 @@ internal static class ViolationEndpoints
HttpContext context,
[FromBody] CreateViolationBatchRequest request,
IViolationEventRepository repository,
TimeProvider timeProvider,
IGuidProvider guidProvider,
CancellationToken cancellationToken)
{
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
@@ -408,9 +413,10 @@ internal static class ViolationEndpoints
});
}
var now = timeProvider.GetUtcNow();
var entities = request.Violations.Select(v => new ViolationEventEntity
{
Id = Guid.NewGuid(),
Id = guidProvider.NewGuid(),
TenantId = tenantId,
PolicyId = v.PolicyId,
RuleId = v.RuleId,
@@ -420,7 +426,7 @@ internal static class ViolationEndpoints
Details = v.Details ?? "{}",
Remediation = v.Remediation,
CorrelationId = v.CorrelationId,
OccurredAt = v.OccurredAt ?? DateTimeOffset.UtcNow
OccurredAt = v.OccurredAt ?? now
}).ToList();
try

View File

@@ -185,7 +185,7 @@ public sealed record VexTrustGateResult
/// <summary>
/// Timestamp when decision was made.
/// </summary>
public DateTimeOffset EvaluatedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Additional details for audit.
@@ -400,7 +400,7 @@ public sealed class VexTrustGate : IVexTrustGate
};
}
private static VexTrustGateResult CreateAllowResult(
private VexTrustGateResult CreateAllowResult(
string gateId,
string reason,
VexTrustStatus? trustStatus)
@@ -415,7 +415,7 @@ public sealed class VexTrustGate : IVexTrustGate
? ComputeTier(trustStatus.TrustScore)
: null,
IssuerId = trustStatus?.IssuerId,
EvaluatedAt = DateTimeOffset.UtcNow
EvaluatedAt = _timeProvider.GetUtcNow()
};
}

View File

@@ -6,12 +6,18 @@ namespace StellaOps.Policy.Engine.Services;
internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
{
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
public InMemoryPolicyPackRepository(TimeProvider timeProvider)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(packId);
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, DateTimeOffset.UtcNow));
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, _timeProvider.GetUtcNow()));
return Task.FromResult(created);
}
@@ -25,15 +31,16 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
{
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
var now = _timeProvider.GetUtcNow();
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, now));
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
var revision = pack.GetOrAddRevision(
revisionVersion,
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, DateTimeOffset.UtcNow));
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, now));
if (revision.Status != initialStatus)
{
revision.SetStatus(initialStatus, DateTimeOffset.UtcNow);
revision.SetStatus(initialStatus, now);
}
return Task.FromResult(revision);
@@ -95,9 +102,10 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
{
ArgumentNullException.ThrowIfNull(bundle);
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
var now = _timeProvider.GetUtcNow();
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, now));
var revision = pack.GetOrAddRevision(version > 0 ? version : pack.GetNextVersion(),
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, DateTimeOffset.UtcNow));
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, now));
revision.SetBundle(bundle);
return Task.FromResult(bundle);

View File

@@ -5,6 +5,7 @@
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Determinism.Abstractions;
using StellaOps.SbomService.Repositories;
namespace StellaOps.Policy.Engine.Services;
@@ -94,13 +95,19 @@ public sealed class VerdictLinkService : IVerdictLinkService
{
private readonly ISbomVerdictLinkRepository _repository;
private readonly ILogger<VerdictLinkService> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public VerdictLinkService(
ISbomVerdictLinkRepository repository,
ILogger<VerdictLinkService> logger)
ILogger<VerdictLinkService> logger,
TimeProvider timeProvider,
IGuidProvider guidProvider)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
/// <inheritdoc/>
@@ -114,14 +121,14 @@ public sealed class VerdictLinkService : IVerdictLinkService
return;
}
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var links = new List<SbomVerdictLink>();
foreach (var verdict in request.Verdicts)
{
var link = new SbomVerdictLink
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
SbomVersionId = request.SbomVersionId,
Cve = verdict.Cve,
ConsensusProjectionId = verdict.ConsensusProjectionId,

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
@@ -10,13 +11,15 @@ namespace StellaOps.Policy.Engine.Storage.InMemory;
/// In-memory implementation of IExceptionRepository for offline/test runs.
/// Provides minimal semantics needed for lifecycle processing.
/// </summary>
public sealed class InMemoryExceptionRepository : IExceptionRepository
public sealed class InMemoryExceptionRepository(TimeProvider timeProvider, IGuidProvider guidProvider) : IExceptionRepository
{
private readonly TimeProvider _timeProvider = timeProvider;
private readonly IGuidProvider _guidProvider = guidProvider;
private readonly ConcurrentDictionary<(string Tenant, Guid Id), ExceptionEntity> _exceptions = new();
public Task<ExceptionEntity> CreateAsync(ExceptionEntity exception, CancellationToken cancellationToken = default)
{
var id = exception.Id == Guid.Empty ? Guid.NewGuid() : exception.Id;
var id = exception.Id == Guid.Empty ? _guidProvider.NewGuid() : exception.Id;
var stored = Copy(exception, id);
_exceptions[(Normalize(exception.TenantId), id)] = stored;
return Task.FromResult(stored);
@@ -123,7 +126,7 @@ public sealed class InMemoryExceptionRepository : IExceptionRepository
_exceptions[key] = Copy(
existing,
statusOverride: ExceptionStatus.Revoked,
revokedAtOverride: DateTimeOffset.UtcNow,
revokedAtOverride: _timeProvider.GetUtcNow(),
revokedByOverride: revokedBy);
return Task.FromResult(true);
}
@@ -133,7 +136,7 @@ public sealed class InMemoryExceptionRepository : IExceptionRepository
public Task<int> ExpireAsync(string tenantId, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var normalizedTenant = Normalize(tenantId);
var expired = 0;

View File

@@ -2,10 +2,12 @@
// Sprint: SPRINT_20251226_003_BE_exception_approval
// Task: EXCEPT-05, EXCEPT-06, EXCEPT-07 - Exception approval API endpoints
using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
@@ -89,6 +91,8 @@ public static class ExceptionApprovalEndpoints
CreateApprovalRequestDto request,
IExceptionApprovalRepository repository,
IExceptionApprovalRulesService rulesService,
TimeProvider timeProvider,
IGuidProvider guidProvider,
ILogger<ExceptionApprovalRequestEntity> logger,
CancellationToken cancellationToken)
{
@@ -110,7 +114,8 @@ public static class ExceptionApprovalEndpoints
}
// Generate request ID
var requestId = $"EAR-{DateTimeOffset.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
var now = timeProvider.GetUtcNow();
var requestId = $"EAR-{now.ToString("yyyyMMdd", CultureInfo.InvariantCulture)}-{guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8].ToUpperInvariant()}";
// Parse gate level
if (!Enum.TryParse<GateLevel>(request.GateLevel, ignoreCase: true, out var gateLevel))
@@ -139,10 +144,9 @@ public static class ExceptionApprovalEndpoints
});
}
var now = DateTimeOffset.UtcNow;
var entity = new ExceptionApprovalRequestEntity
{
Id = Guid.NewGuid(),
Id = guidProvider.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
ExceptionId = request.ExceptionId,
@@ -204,7 +208,7 @@ public static class ExceptionApprovalEndpoints
// Record audit entry
await repository.RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
Id = guidProvider.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = 1,

View File

@@ -8,6 +8,7 @@ using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
using StellaOps.Policy.Gateway.Contracts;
@@ -134,6 +135,8 @@ public static class ExceptionEndpoints
CreateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
IGuidProvider guidProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
@@ -145,8 +148,10 @@ public static class ExceptionEndpoints
});
}
var now = timeProvider.GetUtcNow();
// Validate expiry is in future
if (request.ExpiresAt <= DateTimeOffset.UtcNow)
if (request.ExpiresAt <= now)
{
return Results.BadRequest(new ProblemDetails
{
@@ -157,7 +162,7 @@ public static class ExceptionEndpoints
}
// Validate expiry is not more than 1 year
if (request.ExpiresAt > DateTimeOffset.UtcNow.AddYears(1))
if (request.ExpiresAt > now.AddYears(1))
{
return Results.BadRequest(new ProblemDetails
{
@@ -170,7 +175,7 @@ public static class ExceptionEndpoints
var actorId = GetActorId(context);
var clientInfo = GetClientInfo(context);
var exceptionId = $"EXC-{Guid.NewGuid():N}"[..20];
var exceptionId = $"EXC-{guidProvider.NewGuid():N}"[..20];
var exception = new ExceptionObject
{
@@ -188,8 +193,8 @@ public static class ExceptionEndpoints
},
OwnerId = request.OwnerId,
RequesterId = actorId,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = now,
UpdatedAt = now,
ExpiresAt = request.ExpiresAt,
ReasonCode = ParseReasonRequired(request.ReasonCode),
Rationale = request.Rationale,
@@ -210,6 +215,7 @@ public static class ExceptionEndpoints
UpdateExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -238,7 +244,7 @@ public static class ExceptionEndpoints
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = timeProvider.GetUtcNow(),
Rationale = request.Rationale ?? existing.Rationale,
EvidenceRefs = request.EvidenceRefs?.ToImmutableArray() ?? existing.EvidenceRefs,
CompensatingControls = request.CompensatingControls?.ToImmutableArray() ?? existing.CompensatingControls,
@@ -258,6 +264,7 @@ public static class ExceptionEndpoints
ApproveExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -290,12 +297,13 @@ public static class ExceptionEndpoints
});
}
var now = timeProvider.GetUtcNow();
var updated = existing with
{
Version = existing.Version + 1,
Status = ExceptionStatus.Approved,
UpdatedAt = DateTimeOffset.UtcNow,
ApprovedAt = DateTimeOffset.UtcNow,
UpdatedAt = now,
ApprovedAt = now,
ApproverIds = existing.ApproverIds.Add(actorId)
};
@@ -310,6 +318,7 @@ public static class ExceptionEndpoints
string id,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -335,7 +344,7 @@ public static class ExceptionEndpoints
{
Version = existing.Version + 1,
Status = ExceptionStatus.Active,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = timeProvider.GetUtcNow()
};
var result = await repository.UpdateAsync(
@@ -350,6 +359,7 @@ public static class ExceptionEndpoints
ExtendExceptionRequest request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -384,7 +394,7 @@ public static class ExceptionEndpoints
var updated = existing with
{
Version = existing.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = timeProvider.GetUtcNow(),
ExpiresAt = request.NewExpiresAt
};
@@ -400,6 +410,7 @@ public static class ExceptionEndpoints
[FromBody] RevokeExceptionRequest? request,
HttpContext context,
IExceptionRepository repository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var existing = await repository.GetByIdAsync(id, cancellationToken);
@@ -425,7 +436,7 @@ public static class ExceptionEndpoints
{
Version = existing.Version + 1,
Status = ExceptionStatus.Revoked,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = timeProvider.GetUtcNow()
};
var result = await repository.UpdateAsync(

View File

@@ -2,10 +2,12 @@
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
// Task: CICD-GATE-01 - Create POST /api/v1/policy/gate/evaluate endpoint
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Audit;
using StellaOps.Policy.Deltas;
using StellaOps.Policy.Engine.Gates;
@@ -39,6 +41,8 @@ public static class GateEndpoints
IBaselineSelector baselineSelector,
IGateBypassAuditor bypassAuditor,
IMemoryCache cache,
TimeProvider timeProvider,
IGuidProvider guidProvider,
ILogger<DriftGateEvaluator> logger,
CancellationToken cancellationToken) =>
{
@@ -79,12 +83,12 @@ public static class GateEndpoints
return Results.Ok(new GateEvaluateResponse
{
DecisionId = $"gate:{DateTimeOffset.UtcNow:yyyyMMddHHmmss}:{Guid.NewGuid():N}",
DecisionId = $"gate:{timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}:{guidProvider.NewGuid():N}",
Status = GateStatus.Pass,
ExitCode = GateExitCodes.Pass,
ImageDigest = request.ImageDigest,
BaselineRef = request.BaselineRef,
DecidedAt = DateTimeOffset.UtcNow,
DecidedAt = timeProvider.GetUtcNow(),
Summary = "First build - no baseline for comparison",
Advisory = "This appears to be a first build. Future builds will be compared against this baseline."
});
@@ -224,7 +228,7 @@ public static class GateEndpoints
.WithDescription("Retrieve a previous gate evaluation decision by ID");
// GET /api/v1/policy/gate/health - Health check for gate service
gates.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTimeOffset.UtcNow }))
gates.MapGet("/health", (TimeProvider timeProvider) => Results.Ok(new { status = "healthy", timestamp = timeProvider.GetUtcNow() }))
.WithName("GateHealth")
.WithDescription("Health check for the gate evaluation service");
}

View File

@@ -5,6 +5,8 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Determinism.Abstractions;
namespace StellaOps.Policy.Gateway.Endpoints;
@@ -104,6 +106,7 @@ public static class GovernanceEndpoints
{
var tenant = tenantId ?? GetTenantId(httpContext) ?? "default";
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var response = new SealedModeStatusResponse
{
@@ -118,7 +121,7 @@ public static class GovernanceEndpoints
.Select(MapOverrideToResponse)
.ToList(),
VerificationStatus = "verified",
LastVerifiedAt = DateTimeOffset.UtcNow.ToString("O")
LastVerifiedAt = timeProvider.GetUtcNow().ToString("O")
};
return Task.FromResult(Results.Ok(response));
@@ -144,9 +147,9 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var state = SealedModeStates.GetOrAdd(tenant, _ => new SealedModeState());
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (request.Enable)
{
@@ -173,7 +176,7 @@ public static class GovernanceEndpoints
// Audit
RecordAudit(tenant, actor, "sealed_mode_toggled", "sealed-mode", "system_config",
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}");
$"{(request.Enable ? "Enabled" : "Disabled")} sealed mode: {request.Reason}", timeProvider, guidProvider);
var response = new SealedModeStatusResponse
{
@@ -197,9 +200,11 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
var overrideId = $"override-{Guid.NewGuid():N}";
var overrideId = $"override-{guidProvider.NewGuid():N}";
var entity = new SealedModeOverrideEntity
{
Id = overrideId,
@@ -207,7 +212,7 @@ public static class GovernanceEndpoints
Type = request.Type,
Target = request.Target,
Reason = request.Reason,
ApprovalId = $"approval-{Guid.NewGuid():N}",
ApprovalId = $"approval-{guidProvider.NewGuid():N}",
ApprovedBy = [actor],
ExpiresAt = now.AddHours(request.DurationHours).ToString("O"),
CreatedAt = now.ToString("O"),
@@ -217,7 +222,7 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_created", overrideId, "sealed_mode_override",
$"Created override for {request.Target}: {request.Reason}");
$"Created override for {request.Target}: {request.Reason}", timeProvider, guidProvider);
return Task.FromResult(Results.Ok(MapOverrideToResponse(entity)));
}
@@ -229,6 +234,8 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
if (!Overrides.TryGetValue(overrideId, out var entity) || entity.TenantId != tenant)
{
@@ -243,7 +250,7 @@ public static class GovernanceEndpoints
Overrides[overrideId] = entity;
RecordAudit(tenant, actor, "sealed_mode_override_revoked", overrideId, "sealed_mode_override",
$"Revoked override: {request.Reason}");
$"Revoked override: {request.Reason}", timeProvider, guidProvider);
return Task.FromResult(Results.NoContent());
}
@@ -293,9 +300,11 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
var profileId = $"profile-{Guid.NewGuid():N}";
var profileId = $"profile-{guidProvider.NewGuid():N}";
var entity = new RiskProfileEntity
{
Id = profileId,
@@ -317,7 +326,7 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_created", profileId, "risk_profile",
$"Created risk profile: {request.Name}");
$"Created risk profile: {request.Name}", timeProvider, guidProvider);
return Task.FromResult(Results.Created($"/api/v1/governance/risk-profiles/{profileId}", MapProfileToResponse(entity)));
}
@@ -329,7 +338,9 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
@@ -354,7 +365,7 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_updated", profileId, "risk_profile",
$"Updated risk profile: {entity.Name}");
$"Updated risk profile: {entity.Name}", timeProvider, guidProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
@@ -365,6 +376,8 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
if (!RiskProfiles.TryRemove(profileId, out var removed))
{
@@ -376,7 +389,7 @@ public static class GovernanceEndpoints
}
RecordAudit(tenant, actor, "risk_profile_deleted", profileId, "risk_profile",
$"Deleted risk profile: {removed.Name}");
$"Deleted risk profile: {removed.Name}", timeProvider, guidProvider);
return Task.FromResult(Results.NoContent());
}
@@ -387,7 +400,9 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
@@ -408,7 +423,7 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_activated", profileId, "risk_profile",
$"Activated risk profile: {entity.Name}");
$"Activated risk profile: {entity.Name}", timeProvider, guidProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
@@ -420,7 +435,9 @@ public static class GovernanceEndpoints
{
var tenant = GetTenantId(httpContext) ?? "default";
var actor = GetActorId(httpContext) ?? "system";
var now = DateTimeOffset.UtcNow;
var timeProvider = httpContext.RequestServices.GetRequiredService<TimeProvider>();
var guidProvider = httpContext.RequestServices.GetRequiredService<IGuidProvider>();
var now = timeProvider.GetUtcNow();
if (!RiskProfiles.TryGetValue(profileId, out var existing))
{
@@ -442,7 +459,7 @@ public static class GovernanceEndpoints
RiskProfiles[profileId] = entity;
RecordAudit(tenant, actor, "risk_profile_deprecated", profileId, "risk_profile",
$"Deprecated risk profile: {entity.Name} - {request.Reason}");
$"Deprecated risk profile: {entity.Name} - {request.Reason}", timeProvider, guidProvider);
return Task.FromResult(Results.Ok(MapProfileToResponse(entity)));
}
@@ -542,7 +559,7 @@ public static class GovernanceEndpoints
{
if (RiskProfiles.IsEmpty)
{
var now = DateTimeOffset.UtcNow.ToString("O");
var now = TimeProvider.System.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
RiskProfiles["profile-default"] = new RiskProfileEntity
{
Id = "profile-default",
@@ -582,15 +599,15 @@ public static class GovernanceEndpoints
?? httpContext.Request.Headers["X-StellaOps-Actor"].FirstOrDefault();
}
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary)
private static void RecordAudit(string tenantId, string actor, string eventType, string targetId, string targetType, string summary, TimeProvider timeProvider, IGuidProvider guidProvider)
{
var id = $"audit-{Guid.NewGuid():N}";
var id = $"audit-{guidProvider.NewGuid():N}";
AuditEntries[id] = new GovernanceAuditEntry
{
Id = id,
TenantId = tenantId,
Type = eventType,
Timestamp = DateTimeOffset.UtcNow.ToString("O"),
Timestamp = timeProvider.GetUtcNow().ToString("O", System.Globalization.CultureInfo.InvariantCulture),
Actor = actor,
ActorType = "user",
TargetResource = targetId,

View File

@@ -50,6 +50,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleDockerRegistryWebhook(
[FromBody] DockerRegistryNotification notification,
IGateEvaluationQueue evaluationQueue,
TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -77,7 +78,7 @@ internal static class RegistryWebhookEndpoints
Tag = evt.Target.Tag,
RegistryUrl = evt.Request?.Host,
Source = "docker-registry",
Timestamp = evt.Timestamp ?? DateTimeOffset.UtcNow
Timestamp = evt.Timestamp ?? timeProvider.GetUtcNow()
}, ct);
jobs.Add(jobId);
@@ -100,6 +101,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleHarborWebhook(
[FromBody] HarborWebhookEvent notification,
IGateEvaluationQueue evaluationQueue,
TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -136,7 +138,7 @@ internal static class RegistryWebhookEndpoints
Tag = resource.Tag,
RegistryUrl = notification.EventData.Repository?.RepoFullName,
Source = "harbor",
Timestamp = notification.OccurAt ?? DateTimeOffset.UtcNow
Timestamp = notification.OccurAt ?? timeProvider.GetUtcNow()
}, ct);
jobs.Add(jobId);
@@ -159,6 +161,7 @@ internal static class RegistryWebhookEndpoints
private static async Task<Results<Accepted<WebhookAcceptedResponse>, ProblemHttpResult>> HandleGenericWebhook(
[FromBody] GenericRegistryWebhook notification,
IGateEvaluationQueue evaluationQueue,
TimeProvider timeProvider,
ILogger<RegistryWebhookEndpointMarker> logger,
CancellationToken ct)
{
@@ -177,7 +180,7 @@ internal static class RegistryWebhookEndpoints
RegistryUrl = notification.RegistryUrl,
BaselineRef = notification.BaselineRef,
Source = notification.Source ?? "generic",
Timestamp = DateTimeOffset.UtcNow
Timestamp = timeProvider.GetUtcNow()
}, ct);
logger.LogInformation(

View File

@@ -5,6 +5,7 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
@@ -21,6 +22,7 @@ public sealed class ExceptionService : IExceptionService
private readonly IExceptionRepository _repository;
private readonly IExceptionNotificationService _notificationService;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ILogger<ExceptionService> _logger;
/// <summary>
@@ -30,11 +32,13 @@ public sealed class ExceptionService : IExceptionService
IExceptionRepository repository,
IExceptionNotificationService notificationService,
TimeProvider timeProvider,
IGuidProvider guidProvider,
ILogger<ExceptionService> logger)
{
_repository = repository;
_notificationService = notificationService;
_timeProvider = timeProvider;
_guidProvider = guidProvider;
_logger = logger;
}
@@ -537,10 +541,10 @@ public sealed class ExceptionService : IExceptionService
id.StartsWith("GO-", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateExceptionId()
private string GenerateExceptionId()
{
// Format: EXC-{random alphanumeric}
return $"EXC-{Guid.NewGuid():N}"[..20];
return $"EXC-{_guidProvider.NewGuid():N}"[..20];
}
#endregion

View File

@@ -5,9 +5,11 @@
// Description: In-memory queue for gate evaluation jobs with background processing
// -----------------------------------------------------------------------------
using System.Globalization;
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Gateway.Endpoints;
@@ -21,11 +23,15 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
{
private readonly Channel<GateEvaluationJob> _channel;
private readonly ILogger<InMemoryGateEvaluationQueue> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger)
public InMemoryGateEvaluationQueue(ILogger<InMemoryGateEvaluationQueue> logger, TimeProvider timeProvider, IGuidProvider guidProvider)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger;
_timeProvider = timeProvider;
_guidProvider = guidProvider;
// Bounded channel to prevent unbounded memory growth
_channel = Channel.CreateBounded<GateEvaluationJob>(new BoundedChannelOptions(1000)
@@ -46,7 +52,7 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
{
JobId = jobId,
Request = request,
QueuedAt = DateTimeOffset.UtcNow
QueuedAt = _timeProvider.GetUtcNow()
};
await _channel.Writer.WriteAsync(job, cancellationToken).ConfigureAwait(false);
@@ -65,11 +71,11 @@ public sealed class InMemoryGateEvaluationQueue : IGateEvaluationQueue
/// </summary>
public ChannelReader<GateEvaluationJob> Reader => _channel.Reader;
private static string GenerateJobId()
private string GenerateJobId()
{
// Format: gate-{timestamp}-{random}
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var random = Guid.NewGuid().ToString("N")[..8];
var timestamp = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture);
var random = _guidProvider.NewGuid().ToString("N", CultureInfo.InvariantCulture)[..8];
return $"gate-{timestamp}-{random}";
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Gateway.Options;
namespace StellaOps.Policy.Gateway.Services;
@@ -17,6 +18,7 @@ internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
private readonly IHostEnvironment hostEnvironment;
private readonly IOptionsMonitor<PolicyGatewayOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly IGuidProvider guidProvider;
private readonly ILogger<PolicyGatewayDpopProofGenerator> logger;
private DpopKeyMaterial? keyMaterial;
private readonly object sync = new();
@@ -25,11 +27,13 @@ internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
IHostEnvironment hostEnvironment,
IOptionsMonitor<PolicyGatewayOptions> optionsMonitor,
TimeProvider timeProvider,
IGuidProvider guidProvider,
ILogger<PolicyGatewayDpopProofGenerator> logger)
{
this.hostEnvironment = hostEnvironment ?? throw new ArgumentNullException(nameof(hostEnvironment));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -85,7 +89,7 @@ internal sealed class PolicyGatewayDpopProofGenerator : IDisposable
["htm"] = method.Method.ToUpperInvariant(),
["htu"] = NormalizeTarget(targetUri),
["iat"] = epochSeconds,
["jti"] = Guid.NewGuid().ToString("N")
["jti"] = guidProvider.NewGuid().ToString("N")
};
if (!string.IsNullOrWhiteSpace(accessToken))

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Services;
@@ -13,6 +14,7 @@ public sealed class BatchSimulationOrchestrator : IBatchSimulationOrchestrator,
{
private readonly IPolicySimulationService _simulationService;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), BatchSimulationJob> _jobs = new();
private readonly ConcurrentDictionary<(Guid TenantId, string JobId), List<BatchSimulationInputResult>> _results = new();
private readonly ConcurrentDictionary<string, string> _idempotencyKeys = new();
@@ -22,10 +24,12 @@ public sealed class BatchSimulationOrchestrator : IBatchSimulationOrchestrator,
public BatchSimulationOrchestrator(
IPolicySimulationService simulationService,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null)
{
_simulationService = simulationService ?? throw new ArgumentNullException(nameof(simulationService));
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? GuidProvider.Default;
// Start background processing
_processingTask = Task.Run(ProcessJobsAsync);
@@ -390,9 +394,9 @@ public sealed class BatchSimulationOrchestrator : IBatchSimulationOrchestrator,
};
}
private static string GenerateJobId(Guid tenantId, DateTimeOffset timestamp)
private string GenerateJobId(Guid tenantId, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
var content = $"{tenantId}:{timestamp.ToUnixTimeMilliseconds()}:{_guidProvider.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"batch_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
using StellaOps.Policy.Registry.Storage;
@@ -13,13 +14,18 @@ public sealed class ReviewWorkflowService : IReviewWorkflowService
{
private readonly IPolicyPackStore _packStore;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), ReviewRequest> _reviews = new();
private readonly ConcurrentDictionary<(Guid TenantId, string ReviewId), List<ReviewAuditEntry>> _auditTrails = new();
public ReviewWorkflowService(IPolicyPackStore packStore, TimeProvider? timeProvider = null)
public ReviewWorkflowService(
IPolicyPackStore packStore,
TimeProvider? timeProvider = null,
IGuidProvider? guidProvider = null)
{
_packStore = packStore ?? throw new ArgumentNullException(nameof(packStore));
_timeProvider = timeProvider ?? TimeProvider.System;
_guidProvider = guidProvider ?? GuidProvider.Default;
}
public async Task<ReviewRequest> SubmitForReviewAsync(
@@ -345,9 +351,9 @@ public sealed class ReviewWorkflowService : IReviewWorkflowService
return $"rev_{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
}
private static string GenerateAuditId(Guid tenantId, string reviewId, DateTimeOffset timestamp)
private string GenerateAuditId(Guid tenantId, string reviewId, DateTimeOffset timestamp)
{
var content = $"{tenantId}:{reviewId}:{timestamp.ToUnixTimeMilliseconds()}:{Guid.NewGuid()}";
var content = $"{tenantId}:{reviewId}:{timestamp.ToUnixTimeMilliseconds()}:{_guidProvider.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
return $"aud_{Convert.ToHexString(hash)[..12].ToLowerInvariant()}";
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -9,6 +10,14 @@ namespace StellaOps.Policy.Registry.Storage;
public sealed class InMemoryOverrideStore : IOverrideStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid OverrideId), OverrideEntity> _overrides = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryOverrideStore(TimeProvider timeProvider, IGuidProvider guidProvider)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
}
public Task<OverrideEntity> CreateAsync(
Guid tenantId,
@@ -16,8 +25,8 @@ public sealed class InMemoryOverrideStore : IOverrideStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var overrideId = Guid.NewGuid();
var now = _timeProvider.GetUtcNow();
var overrideId = _guidProvider.NewGuid();
var entity = new OverrideEntity
{
@@ -73,7 +82,7 @@ public sealed class InMemoryOverrideStore : IOverrideStore
return Task.FromResult<OverrideEntity?>(null);
}
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Status = OverrideStatus.Approved,

View File

@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -13,6 +14,14 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), PolicyPackEntity> _packs = new();
private readonly ConcurrentDictionary<(Guid TenantId, Guid PackId), List<PolicyPackHistoryEntry>> _history = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryPolicyPackStore(TimeProvider timeProvider, IGuidProvider guidProvider)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
}
public Task<PolicyPackEntity> CreateAsync(
Guid tenantId,
@@ -20,8 +29,8 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var packId = Guid.NewGuid();
var now = _timeProvider.GetUtcNow();
var packId = _guidProvider.NewGuid();
var entity = new PolicyPackEntity
{
@@ -130,7 +139,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
Description = request.Description ?? existing.Description,
Rules = request.Rules ?? existing.Rules,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = updatedBy
};
@@ -178,7 +187,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
return Task.FromResult<PolicyPackEntity?>(null);
}
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var updated = existing with
{
Status = newStatus,
@@ -228,7 +237,7 @@ public sealed class InMemoryPolicyPackStore : IPolicyPackStore
{
PackId = packId,
Action = action,
Timestamp = DateTimeOffset.UtcNow,
Timestamp = _timeProvider.GetUtcNow(),
PerformedBy = performedBy,
PreviousStatus = previousStatus,
NewStatus = newStatus,

View File

@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -9,8 +10,10 @@ namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of ISnapshotStore for testing and development.
/// </summary>
public sealed class InMemorySnapshotStore : ISnapshotStore
public sealed class InMemorySnapshotStore(TimeProvider timeProvider, IGuidProvider guidProvider) : ISnapshotStore
{
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
private readonly IGuidProvider _guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
private readonly ConcurrentDictionary<(Guid TenantId, Guid SnapshotId), SnapshotEntity> _snapshots = new();
public Task<SnapshotEntity> CreateAsync(
@@ -19,8 +22,8 @@ public sealed class InMemorySnapshotStore : ISnapshotStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var snapshotId = Guid.NewGuid();
var now = _timeProvider.GetUtcNow();
var snapshotId = _guidProvider.NewGuid();
// Compute digest from pack IDs and timestamp for uniqueness
var digest = ComputeDigest(request.PackIds, now);

View File

@@ -6,8 +6,9 @@ namespace StellaOps.Policy.Registry.Storage;
/// <summary>
/// In-memory implementation of IVerificationPolicyStore for testing and development.
/// </summary>
public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
public sealed class InMemoryVerificationPolicyStore(TimeProvider timeProvider) : IVerificationPolicyStore
{
private readonly TimeProvider _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
private readonly ConcurrentDictionary<(Guid TenantId, string PolicyId), VerificationPolicyEntity> _policies = new();
public Task<VerificationPolicyEntity> CreateAsync(
@@ -16,7 +17,7 @@ public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
string? createdBy = null,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var entity = new VerificationPolicyEntity
{
@@ -102,7 +103,7 @@ public sealed class InMemoryVerificationPolicyStore : IVerificationPolicyStore
SignerRequirements = request.SignerRequirements ?? existing.SignerRequirements,
ValidityWindow = request.ValidityWindow ?? existing.ValidityWindow,
Metadata = request.Metadata ?? existing.Metadata,
UpdatedAt = DateTimeOffset.UtcNow,
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = updatedBy
};

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry.Storage;
@@ -9,14 +10,22 @@ namespace StellaOps.Policy.Registry.Storage;
public sealed class InMemoryViolationStore : IViolationStore
{
private readonly ConcurrentDictionary<(Guid TenantId, Guid ViolationId), ViolationEntity> _violations = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public InMemoryViolationStore(TimeProvider timeProvider, IGuidProvider guidProvider)
{
_timeProvider = timeProvider;
_guidProvider = guidProvider;
}
public Task<ViolationEntity> AppendAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var violationId = Guid.NewGuid();
var now = _timeProvider.GetUtcNow();
var violationId = _guidProvider.NewGuid();
var entity = new ViolationEntity
{
@@ -42,7 +51,7 @@ public sealed class InMemoryViolationStore : IViolationStore
IReadOnlyList<CreateViolationRequest> requests,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
int created = 0;
int failed = 0;
var errors = new List<BatchError>();
@@ -52,7 +61,7 @@ public sealed class InMemoryViolationStore : IViolationStore
try
{
var request = requests[i];
var violationId = Guid.NewGuid();
var violationId = _guidProvider.NewGuid();
var entity = new ViolationEntity
{

View File

@@ -5,6 +5,7 @@ using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Attestor.Envelope;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Scoring.Engine;
namespace StellaOps.Policy.Scoring.Receipts;
@@ -45,12 +46,16 @@ public sealed class ReceiptBuilder : IReceiptBuilder
private readonly ICvssV4Engine _engine;
private readonly IReceiptRepository _repository;
private readonly EnvelopeSignatureService _signatureService;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public ReceiptBuilder(ICvssV4Engine engine, IReceiptRepository repository)
public ReceiptBuilder(ICvssV4Engine engine, IReceiptRepository repository, TimeProvider timeProvider, IGuidProvider guidProvider)
{
_engine = engine;
_repository = repository;
_signatureService = new EnvelopeSignatureService();
_timeProvider = timeProvider;
_guidProvider = guidProvider;
}
public async Task<CvssScoreReceipt> CreateAsync(CreateReceiptRequest request, CancellationToken cancellationToken = default)
@@ -60,7 +65,7 @@ public sealed class ReceiptBuilder : IReceiptBuilder
ValidateEvidence(request.Policy, request.Evidence);
var createdAt = request.CreatedAt ?? DateTimeOffset.UtcNow;
var createdAt = request.CreatedAt ?? _timeProvider.GetUtcNow();
// Compute scores and vector
var scores = _engine.ComputeScores(request.BaseMetrics, request.ThreatMetrics, request.EnvironmentalMetrics);
@@ -83,7 +88,7 @@ public sealed class ReceiptBuilder : IReceiptBuilder
var receipt = new CvssScoreReceipt
{
ReceiptId = Guid.NewGuid().ToString("N"),
ReceiptId = _guidProvider.NewGuid().ToString("N"),
TenantId = request.TenantId,
VulnerabilityId = request.VulnerabilityId,
CreatedAt = createdAt,
@@ -103,7 +108,7 @@ public sealed class ReceiptBuilder : IReceiptBuilder
InputHash = ComputeInputHash(request, scores, policyRef, vector, evidence),
History = ImmutableList<ReceiptHistoryEntry>.Empty.Add(new ReceiptHistoryEntry
{
HistoryId = Guid.NewGuid().ToString("N"),
HistoryId = _guidProvider.NewGuid().ToString("N"),
Timestamp = createdAt,
Actor = request.CreatedBy,
ChangeType = ReceiptChangeType.Created,

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using StellaOps.Attestor.Envelope;
using StellaOps.Determinism.Abstractions;
namespace StellaOps.Policy.Scoring.Receipts;
@@ -25,10 +26,14 @@ public sealed class ReceiptHistoryService : IReceiptHistoryService
{
private readonly IReceiptRepository _repository;
private readonly EnvelopeSignatureService _signatureService = new();
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public ReceiptHistoryService(IReceiptRepository repository)
public ReceiptHistoryService(IReceiptRepository repository, TimeProvider timeProvider, IGuidProvider guidProvider)
{
_repository = repository;
_timeProvider = timeProvider;
_guidProvider = guidProvider;
}
public async Task<CvssScoreReceipt> AmendAsync(AmendReceiptRequest request, CancellationToken cancellationToken = default)
@@ -38,8 +43,8 @@ public sealed class ReceiptHistoryService : IReceiptHistoryService
var existing = await _repository.GetAsync(request.TenantId, request.ReceiptId, cancellationToken)
?? throw new InvalidOperationException($"Receipt '{request.ReceiptId}' not found.");
var now = DateTimeOffset.UtcNow;
var historyId = Guid.NewGuid().ToString("N");
var now = _timeProvider.GetUtcNow();
var historyId = _guidProvider.NewGuid().ToString("N");
var newHistory = existing.History.Add(new ReceiptHistoryEntry
{

View File

@@ -1,3 +1,126 @@
using System.Collections.Immutable;
namespace StellaOps.Policy.Exceptions.Models;
public sealed record ExceptionApplication{public Guid Id{get;init;}public Guid TenantId{get;init;}public required string ExceptionId{get;init;}public required string FindingId{get;init;}public string? VulnerabilityId{get;init;}public required string OriginalStatus{get;init;}public required string AppliedStatus{get;init;}public required string EffectName{get;init;}public required string EffectType{get;init;}public Guid? EvaluationRunId{get;init;}public string? PolicyBundleDigest{get;init;}public DateTimeOffset AppliedAt{get;init;}public ImmutableDictionary<string,string> Metadata{get;init;}=ImmutableDictionary<string,string>.Empty;public static ExceptionApplication Create(Guid tenantId,string exceptionId,string findingId,string originalStatus,string appliedStatus,string effectName,string effectType,string? vulnerabilityId=null,Guid? evaluationRunId=null,string? policyBundleDigest=null,ImmutableDictionary<string,string>? metadata=null){ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);ArgumentException.ThrowIfNullOrWhiteSpace(findingId);return new ExceptionApplication{Id=Guid.NewGuid(),TenantId=tenantId,ExceptionId=exceptionId,FindingId=findingId,VulnerabilityId=vulnerabilityId,OriginalStatus=originalStatus,AppliedStatus=appliedStatus,EffectName=effectName,EffectType=effectType,EvaluationRunId=evaluationRunId,PolicyBundleDigest=policyBundleDigest,AppliedAt=DateTimeOffset.UtcNow,Metadata=metadata??ImmutableDictionary<string,string>.Empty};}}
/// <summary>
/// Represents an application of an exception to a specific finding.
/// </summary>
public sealed record ExceptionApplication
{
/// <summary>
/// Unique identifier for this application.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// The exception that was applied.
/// </summary>
public required string ExceptionId { get; init; }
/// <summary>
/// The finding this exception was applied to.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// Optional vulnerability identifier.
/// </summary>
public string? VulnerabilityId { get; init; }
/// <summary>
/// The original status before the exception was applied.
/// </summary>
public required string OriginalStatus { get; init; }
/// <summary>
/// The status after the exception was applied.
/// </summary>
public required string AppliedStatus { get; init; }
/// <summary>
/// Name of the exception effect.
/// </summary>
public required string EffectName { get; init; }
/// <summary>
/// Type of the exception effect.
/// </summary>
public required string EffectType { get; init; }
/// <summary>
/// Optional evaluation run identifier.
/// </summary>
public Guid? EvaluationRunId { get; init; }
/// <summary>
/// Optional policy bundle digest.
/// </summary>
public string? PolicyBundleDigest { get; init; }
/// <summary>
/// Timestamp when the exception was applied.
/// </summary>
public DateTimeOffset AppliedAt { get; init; }
/// <summary>
/// Additional metadata.
/// </summary>
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
/// <summary>
/// Creates a new exception application with the specified parameters.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="exceptionId">Exception identifier.</param>
/// <param name="findingId">Finding identifier.</param>
/// <param name="originalStatus">Original status before exception.</param>
/// <param name="appliedStatus">Status after exception.</param>
/// <param name="effectName">Name of the effect.</param>
/// <param name="effectType">Type of the effect.</param>
/// <param name="applicationId">Application ID for determinism. Required.</param>
/// <param name="appliedAt">Timestamp for determinism. Required.</param>
/// <param name="vulnerabilityId">Optional vulnerability ID.</param>
/// <param name="evaluationRunId">Optional evaluation run ID.</param>
/// <param name="policyBundleDigest">Optional policy bundle digest.</param>
/// <param name="metadata">Optional metadata.</param>
public static ExceptionApplication Create(
Guid tenantId,
string exceptionId,
string findingId,
string originalStatus,
string appliedStatus,
string effectName,
string effectType,
Guid applicationId,
DateTimeOffset appliedAt,
string? vulnerabilityId = null,
Guid? evaluationRunId = null,
string? policyBundleDigest = null,
ImmutableDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(exceptionId);
ArgumentException.ThrowIfNullOrWhiteSpace(findingId);
return new ExceptionApplication
{
Id = applicationId,
TenantId = tenantId,
ExceptionId = exceptionId,
FindingId = findingId,
VulnerabilityId = vulnerabilityId,
OriginalStatus = originalStatus,
AppliedStatus = appliedStatus,
EffectName = effectName,
EffectType = effectType,
EvaluationRunId = evaluationRunId,
PolicyBundleDigest = policyBundleDigest,
AppliedAt = appliedAt,
Metadata = metadata ?? ImmutableDictionary<string, string>.Empty
};
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using StellaOps.Determinism.Abstractions;
namespace StellaOps.Policy.Exceptions.Models;
@@ -120,15 +121,17 @@ public sealed record ExceptionEvent
public static ExceptionEvent ForCreated(
string exceptionId,
string actorId,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? clientInfo = null) => new()
{
EventId = Guid.NewGuid(),
EventId = guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = 1,
EventType = ExceptionEventType.Created,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = timeProvider.GetUtcNow(),
PreviousStatus = null,
NewStatus = ExceptionStatus.Proposed,
NewVersion = 1,
@@ -144,15 +147,17 @@ public sealed record ExceptionEvent
int sequenceNumber,
string actorId,
int newVersion,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? clientInfo = null) => new()
{
EventId = Guid.NewGuid(),
EventId = guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Approved,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = timeProvider.GetUtcNow(),
PreviousStatus = ExceptionStatus.Proposed,
NewStatus = ExceptionStatus.Approved,
NewVersion = newVersion,
@@ -169,15 +174,17 @@ public sealed record ExceptionEvent
string actorId,
int newVersion,
ExceptionStatus previousStatus,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? description = null,
string? clientInfo = null) => new()
{
EventId = Guid.NewGuid(),
EventId = guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Activated,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = timeProvider.GetUtcNow(),
PreviousStatus = previousStatus,
NewStatus = ExceptionStatus.Active,
NewVersion = newVersion,
@@ -195,14 +202,16 @@ public sealed record ExceptionEvent
int newVersion,
ExceptionStatus previousStatus,
string reason,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? clientInfo = null) => new()
{
EventId = Guid.NewGuid(),
EventId = guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Revoked,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = timeProvider.GetUtcNow(),
PreviousStatus = previousStatus,
NewStatus = ExceptionStatus.Revoked,
NewVersion = newVersion,
@@ -217,14 +226,16 @@ public sealed record ExceptionEvent
public static ExceptionEvent ForExpired(
string exceptionId,
int sequenceNumber,
int newVersion) => new()
int newVersion,
TimeProvider timeProvider,
IGuidProvider guidProvider) => new()
{
EventId = Guid.NewGuid(),
EventId = guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Expired,
ActorId = "system",
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = timeProvider.GetUtcNow(),
PreviousStatus = ExceptionStatus.Active,
NewStatus = ExceptionStatus.Expired,
NewVersion = newVersion,
@@ -241,15 +252,17 @@ public sealed record ExceptionEvent
int newVersion,
DateTimeOffset previousExpiry,
DateTimeOffset newExpiry,
TimeProvider timeProvider,
IGuidProvider guidProvider,
string? reason = null,
string? clientInfo = null) => new()
{
EventId = Guid.NewGuid(),
EventId = guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = ExceptionEventType.Extended,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = timeProvider.GetUtcNow(),
PreviousStatus = ExceptionStatus.Active,
NewStatus = ExceptionStatus.Active,
NewVersion = newVersion,

View File

@@ -295,15 +295,19 @@ public sealed record ExceptionObject
LastRecheckResult.RecommendedAction == RecheckAction.RequireReapproval;
/// <summary>
/// Determines if this exception is currently effective.
/// Determines if this exception is currently effective at the given reference time.
/// </summary>
public bool IsEffective =>
/// <param name="referenceTime">The time to evaluate against.</param>
/// <returns>True if status is Active and not yet expired.</returns>
public bool IsEffectiveAt(DateTimeOffset referenceTime) =>
Status == ExceptionStatus.Active &&
DateTimeOffset.UtcNow < ExpiresAt;
referenceTime < ExpiresAt;
/// <summary>
/// Determines if this exception has expired.
/// Determines if this exception has expired at the given reference time.
/// </summary>
public bool HasExpired =>
DateTimeOffset.UtcNow >= ExpiresAt;
/// <param name="referenceTime">The time to evaluate against.</param>
/// <returns>True if the reference time is at or past the expiration.</returns>
public bool HasExpiredAt(DateTimeOffset referenceTime) =>
referenceTime >= ExpiresAt;
}

View File

@@ -7,6 +7,7 @@ using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Exceptions.Models;
namespace StellaOps.Policy.Exceptions.Repositories;
@@ -18,6 +19,8 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresExceptionRepository> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -30,10 +33,18 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
/// </summary>
/// <param name="dataSource">The PostgreSQL data source.</param>
/// <param name="logger">The logger.</param>
public PostgresExceptionRepository(NpgsqlDataSource dataSource, ILogger<PostgresExceptionRepository> logger)
/// <param name="timeProvider">The time provider for deterministic timestamps.</param>
/// <param name="guidProvider">The GUID provider for deterministic IDs.</param>
public PostgresExceptionRepository(
NpgsqlDataSource dataSource,
ILogger<PostgresExceptionRepository> logger,
TimeProvider timeProvider,
IGuidProvider guidProvider)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
/// <inheritdoc />
@@ -73,7 +84,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
""";
await using var insertCmd = new NpgsqlCommand(insertSql, connection, transaction);
AddExceptionParameters(insertCmd, exception, Guid.NewGuid());
AddExceptionParameters(insertCmd, exception, _guidProvider.NewGuid());
await using var reader = await insertCmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
@@ -523,7 +534,7 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
#region Private Helper Methods
private static ExceptionEvent CreateEventForType(
private ExceptionEvent CreateEventForType(
ExceptionEventType eventType,
string exceptionId,
int sequenceNumber,
@@ -536,12 +547,12 @@ public sealed class PostgresExceptionRepository : IExceptionRepository
{
return new ExceptionEvent
{
EventId = Guid.NewGuid(),
EventId = _guidProvider.NewGuid(),
ExceptionId = exceptionId,
SequenceNumber = sequenceNumber,
EventType = eventType,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = _timeProvider.GetUtcNow(),
PreviousStatus = previousStatus,
NewStatus = newStatus,
NewVersion = newVersion,

View File

@@ -15,19 +15,22 @@ public sealed class EvidenceRequirementValidator : IEvidenceRequirementValidator
private readonly ITrustScoreService _trustScoreService;
private readonly IEvidenceSchemaValidator _schemaValidator;
private readonly ILogger<EvidenceRequirementValidator> _logger;
private readonly TimeProvider _timeProvider;
public EvidenceRequirementValidator(
IEvidenceHookRegistry hookRegistry,
IAttestationVerifier attestationVerifier,
ITrustScoreService trustScoreService,
IEvidenceSchemaValidator schemaValidator,
ILogger<EvidenceRequirementValidator> logger)
ILogger<EvidenceRequirementValidator> logger,
TimeProvider? timeProvider = null)
{
_hookRegistry = hookRegistry ?? throw new ArgumentNullException(nameof(hookRegistry));
_attestationVerifier = attestationVerifier ?? throw new ArgumentNullException(nameof(attestationVerifier));
_trustScoreService = trustScoreService ?? throw new ArgumentNullException(nameof(trustScoreService));
_schemaValidator = schemaValidator ?? throw new ArgumentNullException(nameof(schemaValidator));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
@@ -106,7 +109,7 @@ public sealed class EvidenceRequirementValidator : IEvidenceRequirementValidator
{
if (hook.MaxAge.HasValue)
{
var age = DateTimeOffset.UtcNow - evidence.SubmittedAt;
var age = _timeProvider.GetUtcNow() - evidence.SubmittedAt;
if (age > hook.MaxAge.Value)
{
return (false, $"Evidence is stale (age: {age.TotalHours:F0}h, max: {hook.MaxAge.Value.TotalHours:F0}h)");

View File

@@ -86,10 +86,14 @@ public interface IExceptionEvaluator
public sealed class ExceptionEvaluator : IExceptionEvaluator
{
private readonly IExceptionRepository _repository;
private readonly TimeProvider _timeProvider;
public ExceptionEvaluator(IExceptionRepository repository)
public ExceptionEvaluator(
IExceptionRepository repository,
TimeProvider? timeProvider = null)
{
_repository = repository;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -114,8 +118,9 @@ public sealed class ExceptionEvaluator : IExceptionEvaluator
var candidates = await _repository.GetActiveByScopeAsync(scope, cancellationToken);
// Filter to only those that truly match the context
var referenceTime = _timeProvider.GetUtcNow();
var matching = candidates
.Where(ex => MatchesContext(ex, context))
.Where(ex => MatchesContext(ex, context, referenceTime))
.OrderByDescending(ex => GetSpecificity(ex))
.ToList();
@@ -160,7 +165,7 @@ public sealed class ExceptionEvaluator : IExceptionEvaluator
/// <summary>
/// Determines if an exception matches the given finding context.
/// </summary>
private static bool MatchesContext(ExceptionObject exception, FindingContext context)
private static bool MatchesContext(ExceptionObject exception, FindingContext context, DateTimeOffset referenceTime)
{
var scope = exception.Scope;
@@ -207,7 +212,7 @@ public sealed class ExceptionEvaluator : IExceptionEvaluator
}
// Check if exception is still effective (not expired)
if (!exception.IsEffective)
if (!exception.IsEffectiveAt(referenceTime))
return false;
return true;

View File

@@ -22,8 +22,9 @@ public static class LegacyDocumentConverter
/// Converts a legacy PolicyDocument (as JSON) to PackMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the legacy document.</param>
/// <param name="migrationTimestamp">Timestamp to use for missing dates in legacy documents.</param>
/// <returns>Migration data transfer object.</returns>
public static PackMigrationData ConvertPackFromJson(string json)
public static PackMigrationData ConvertPackFromJson(string json, DateTimeOffset migrationTimestamp)
{
ArgumentException.ThrowIfNullOrEmpty(json);
@@ -41,8 +42,8 @@ public static class LegacyDocumentConverter
LatestVersion = GetInt(root, "latestVersion", 0),
IsBuiltin = GetBool(root, "isBuiltin", false),
Metadata = ExtractMetadata(root),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", DateTimeOffset.UtcNow),
CreatedAt = GetDateTimeOffset(root, "createdAt", migrationTimestamp),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", migrationTimestamp),
CreatedBy = GetString(root, "createdBy")
};
}
@@ -51,8 +52,9 @@ public static class LegacyDocumentConverter
/// Converts a legacy PolicyRevisionDocument (as JSON) to PackVersionMigrationData.
/// </summary>
/// <param name="json">The JSON representation of the legacy document.</param>
/// <param name="migrationTimestamp">Timestamp to use for missing dates in legacy documents.</param>
/// <returns>Migration data transfer object.</returns>
public static PackVersionMigrationData ConvertVersionFromJson(string json)
public static PackVersionMigrationData ConvertVersionFromJson(string json, DateTimeOffset migrationTimestamp)
{
ArgumentException.ThrowIfNullOrEmpty(json);
@@ -71,7 +73,7 @@ public static class LegacyDocumentConverter
IsPublished = isPublished,
PublishedAt = isPublished ? GetNullableDateTimeOffset(root, "activatedAt") : null,
PublishedBy = GetString(root, "publishedBy"),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
CreatedAt = GetDateTimeOffset(root, "createdAt", migrationTimestamp),
CreatedBy = GetString(root, "createdBy")
};
}
@@ -81,11 +83,13 @@ public static class LegacyDocumentConverter
/// </summary>
/// <param name="name">Rule name.</param>
/// <param name="content">Rego content.</param>
/// <param name="migrationTimestamp">Timestamp to use for creation date.</param>
/// <param name="severity">Optional severity.</param>
/// <returns>Rule migration data.</returns>
public static RuleMigrationData CreateRuleFromContent(
string name,
string content,
DateTimeOffset migrationTimestamp,
string? severity = null)
{
return new RuleMigrationData
@@ -94,7 +98,7 @@ public static class LegacyDocumentConverter
Content = content,
RuleType = "rego",
Severity = severity ?? "medium",
CreatedAt = DateTimeOffset.UtcNow
CreatedAt = migrationTimestamp
};
}
@@ -102,8 +106,9 @@ public static class LegacyDocumentConverter
/// Parses multiple pack documents from a JSON array.
/// </summary>
/// <param name="jsonArray">JSON array of pack documents.</param>
/// <param name="migrationTimestamp">Timestamp to use for missing dates in legacy documents.</param>
/// <returns>List of migration data objects.</returns>
public static IReadOnlyList<PackMigrationData> ConvertPacksFromJsonArray(string jsonArray)
public static IReadOnlyList<PackMigrationData> ConvertPacksFromJsonArray(string jsonArray, DateTimeOffset migrationTimestamp)
{
ArgumentException.ThrowIfNullOrEmpty(jsonArray);
@@ -117,7 +122,7 @@ public static class LegacyDocumentConverter
foreach (var element in doc.RootElement.EnumerateArray())
{
results.Add(ConvertPackElement(element));
results.Add(ConvertPackElement(element, migrationTimestamp));
}
return results;
@@ -127,8 +132,9 @@ public static class LegacyDocumentConverter
/// Parses multiple version documents from a JSON array.
/// </summary>
/// <param name="jsonArray">JSON array of version documents.</param>
/// <param name="migrationTimestamp">Timestamp to use for missing dates in legacy documents.</param>
/// <returns>List of migration data objects.</returns>
public static IReadOnlyList<PackVersionMigrationData> ConvertVersionsFromJsonArray(string jsonArray)
public static IReadOnlyList<PackVersionMigrationData> ConvertVersionsFromJsonArray(string jsonArray, DateTimeOffset migrationTimestamp)
{
ArgumentException.ThrowIfNullOrEmpty(jsonArray);
@@ -142,13 +148,13 @@ public static class LegacyDocumentConverter
foreach (var element in doc.RootElement.EnumerateArray())
{
results.Add(ConvertVersionElement(element));
results.Add(ConvertVersionElement(element, migrationTimestamp));
}
return results;
}
private static PackMigrationData ConvertPackElement(JsonElement root)
private static PackMigrationData ConvertPackElement(JsonElement root, DateTimeOffset migrationTimestamp)
{
return new PackMigrationData
{
@@ -161,13 +167,13 @@ public static class LegacyDocumentConverter
LatestVersion = GetInt(root, "latestVersion", 0),
IsBuiltin = GetBool(root, "isBuiltin", false),
Metadata = ExtractMetadata(root),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", DateTimeOffset.UtcNow),
CreatedAt = GetDateTimeOffset(root, "createdAt", migrationTimestamp),
UpdatedAt = GetDateTimeOffset(root, "updatedAt", migrationTimestamp),
CreatedBy = GetString(root, "createdBy")
};
}
private static PackVersionMigrationData ConvertVersionElement(JsonElement root)
private static PackVersionMigrationData ConvertVersionElement(JsonElement root, DateTimeOffset migrationTimestamp)
{
var status = GetString(root, "status") ?? "Draft";
var isPublished = status == "Active" || status == "Approved";
@@ -181,7 +187,7 @@ public static class LegacyDocumentConverter
IsPublished = isPublished,
PublishedAt = isPublished ? GetNullableDateTimeOffset(root, "activatedAt") : null,
PublishedBy = GetString(root, "publishedBy"),
CreatedAt = GetDateTimeOffset(root, "createdAt", DateTimeOffset.UtcNow),
CreatedAt = GetDateTimeOffset(root, "createdAt", migrationTimestamp),
CreatedBy = GetString(root, "createdBy")
};
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using StellaOps.Determinism.Abstractions;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
@@ -18,17 +19,23 @@ public sealed class PolicyMigrator
private readonly IPackVersionRepository _versionRepository;
private readonly IRuleRepository _ruleRepository;
private readonly ILogger<PolicyMigrator> _logger;
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public PolicyMigrator(
IPackRepository packRepository,
IPackVersionRepository versionRepository,
IRuleRepository ruleRepository,
ILogger<PolicyMigrator> logger)
ILogger<PolicyMigrator> logger,
TimeProvider timeProvider,
IGuidProvider guidProvider)
{
_packRepository = packRepository ?? throw new ArgumentNullException(nameof(packRepository));
_versionRepository = versionRepository ?? throw new ArgumentNullException(nameof(versionRepository));
_ruleRepository = ruleRepository ?? throw new ArgumentNullException(nameof(ruleRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
/// <summary>
@@ -76,7 +83,7 @@ public sealed class PolicyMigrator
// Create pack entity
var packEntity = new PackEntity
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
TenantId = pack.TenantId,
Name = pack.Name,
DisplayName = pack.DisplayName,
@@ -154,7 +161,7 @@ public sealed class PolicyMigrator
var versionEntity = new PackVersionEntity
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
PackId = packId,
Version = version.Version,
Description = version.Description,
@@ -176,7 +183,7 @@ public sealed class PolicyMigrator
{
var ruleEntity = new RuleEntity
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
PackVersionId = createdVersion.Id,
Name = rule.Name,
Description = rule.Description,
@@ -187,7 +194,7 @@ public sealed class PolicyMigrator
Category = rule.Category,
Tags = rule.Tags ?? [],
Metadata = rule.Metadata ?? "{}",
CreatedAt = rule.CreatedAt ?? DateTimeOffset.UtcNow
CreatedAt = rule.CreatedAt ?? _timeProvider.GetUtcNow()
};
await _ruleRepository.CreateAsync(ruleEntity, cancellationToken);

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism.Abstractions;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Persistence.Postgres.Models;
@@ -10,9 +11,18 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// </summary>
public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSource>, IExceptionApprovalRepository
{
public ExceptionApprovalRepository(PolicyDataSource dataSource, ILogger<ExceptionApprovalRepository> logger)
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
public ExceptionApprovalRepository(
PolicyDataSource dataSource,
ILogger<ExceptionApprovalRepository> logger,
TimeProvider timeProvider,
IGuidProvider guidProvider)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
// ========================================================================
@@ -279,13 +289,14 @@ public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSourc
? ApprovalRequestStatus.Approved
: ApprovalRequestStatus.Partial;
var now = _timeProvider.GetUtcNow();
var updated = request with
{
ApprovedByIds = approvedByIds,
Status = newStatus,
ResolvedAt = newStatus == ApprovalRequestStatus.Approved ? DateTimeOffset.UtcNow : null,
ResolvedAt = newStatus == ApprovalRequestStatus.Approved ? now : null,
Version = request.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = now
};
if (await UpdateRequestAsync(updated, request.Version, ct))
@@ -293,13 +304,13 @@ public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSourc
// Record audit entry
await RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
ActionType = "approved",
ActorId = approverId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = now,
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
NewStatus = newStatus.ToString().ToLowerInvariant(),
Description = comment ?? $"Approved by {approverId}"
@@ -325,27 +336,28 @@ public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSourc
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
return request;
var now = _timeProvider.GetUtcNow();
var updated = request with
{
RejectedById = rejectorId,
Status = ApprovalRequestStatus.Rejected,
ResolvedAt = DateTimeOffset.UtcNow,
ResolvedAt = now,
RejectionReason = reason,
Version = request.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = now
};
if (await UpdateRequestAsync(updated, request.Version, ct))
{
await RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
ActionType = "rejected",
ActorId = rejectorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = now,
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
NewStatus = "rejected",
Description = reason
@@ -371,25 +383,26 @@ public sealed class ExceptionApprovalRepository : RepositoryBase<PolicyDataSourc
if (request.Status is not (ApprovalRequestStatus.Pending or ApprovalRequestStatus.Partial))
return false;
var now = _timeProvider.GetUtcNow();
var updated = request with
{
Status = ApprovalRequestStatus.Cancelled,
ResolvedAt = DateTimeOffset.UtcNow,
ResolvedAt = now,
Version = request.Version + 1,
UpdatedAt = DateTimeOffset.UtcNow
UpdatedAt = now
};
if (await UpdateRequestAsync(updated, request.Version, ct))
{
await RecordAuditAsync(new ExceptionApprovalAuditEntity
{
Id = Guid.NewGuid(),
Id = _guidProvider.NewGuid(),
RequestId = requestId,
TenantId = tenantId,
SequenceNumber = await GetNextSequenceAsync(tenantId, requestId, ct),
ActionType = "cancelled",
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = now,
PreviousStatus = request.Status.ToString().ToLowerInvariant(),
NewStatus = "cancelled",
Description = reason ?? "Request cancelled by requestor"

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism.Abstractions;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Persistence.Postgres.Models;
@@ -10,8 +11,16 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// </summary>
public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IExplanationRepository
{
public ExplanationRepository(PolicyDataSource dataSource, ILogger<ExplanationRepository> logger)
: base(dataSource, logger) { }
private readonly IGuidProvider _guidProvider;
public ExplanationRepository(
PolicyDataSource dataSource,
ILogger<ExplanationRepository> logger,
IGuidProvider guidProvider)
: base(dataSource, logger)
{
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
public async Task<ExplanationEntity?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
@@ -68,7 +77,7 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
VALUES (@id, @evaluation_run_id, @rule_id, @rule_name, @result, @severity, @message, @details::jsonb, @remediation, @resource_path, @line_number)
RETURNING *
""";
var id = explanation.Id == Guid.Empty ? Guid.NewGuid() : explanation.Id;
var id = explanation.Id == Guid.Empty ? _guidProvider.NewGuid() : explanation.Id;
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
@@ -99,7 +108,7 @@ public sealed class ExplanationRepository : RepositoryBase<PolicyDataSource>, IE
foreach (var explanation in explanations)
{
await using var command = CreateCommand(sql, connection);
var id = explanation.Id == Guid.Empty ? Guid.NewGuid() : explanation.Id;
var id = explanation.Id == Guid.Empty ? _guidProvider.NewGuid() : explanation.Id;
AddParameter(command, "id", id);
AddParameter(command, "evaluation_run_id", explanation.EvaluationRunId);
AddParameter(command, "rule_id", explanation.RuleId);

View File

@@ -3,6 +3,7 @@ using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Determinism.Abstractions;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Exceptions.Models;
using StellaOps.Policy.Exceptions.Repositories;
@@ -19,6 +20,9 @@ namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// </remarks>
public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDataSource>, IAuditableExceptionRepository
{
private readonly TimeProvider _timeProvider;
private readonly IGuidProvider _guidProvider;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
@@ -28,9 +32,15 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
/// <summary>
/// Creates a new exception object repository.
/// </summary>
public PostgresExceptionObjectRepository(PolicyDataSource dataSource, ILogger<PostgresExceptionObjectRepository> logger)
public PostgresExceptionObjectRepository(
PolicyDataSource dataSource,
ILogger<PostgresExceptionObjectRepository> logger,
TimeProvider timeProvider,
IGuidProvider guidProvider)
: base(dataSource, logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
}
/// <inheritdoc />
@@ -194,12 +204,12 @@ public sealed class PostgresExceptionObjectRepository : RepositoryBase<PolicyDat
// Insert event
var updateEvent = new ExceptionEvent
{
EventId = Guid.NewGuid(),
EventId = _guidProvider.NewGuid(),
ExceptionId = exception.ExceptionId,
SequenceNumber = sequenceNumber,
EventType = eventType,
ActorId = actorId,
OccurredAt = DateTimeOffset.UtcNow,
OccurredAt = _timeProvider.GetUtcNow(),
PreviousStatus = currentStatus,
NewStatus = exception.Status,
NewVersion = exception.Version,

View File

@@ -70,23 +70,23 @@ public sealed record PolicyExplanation(
/// <param name="inputs">Optional evaluated inputs.</param>
/// <param name="policyVersion">Optional policy version.</param>
/// <param name="correlationId">Optional correlation ID.</param>
/// <param name="evaluatedAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
/// <param name="evaluatedAt">Timestamp for the evaluation. Required for determinism.</param>
public static PolicyExplanation Create(
string findingId,
PolicyVerdictStatus decision,
string? ruleName,
string reason,
IEnumerable<PolicyExplanationNode> nodes,
DateTimeOffset evaluatedAt,
IEnumerable<RuleHit>? ruleHits = null,
IDictionary<string, object?>? inputs = null,
string? policyVersion = null,
string? correlationId = null,
DateTimeOffset? evaluatedAt = null) =>
string? correlationId = null) =>
new(findingId, decision, ruleName, reason, nodes.ToImmutableArray())
{
RuleHits = ruleHits?.ToImmutableArray() ?? ImmutableArray<RuleHit>.Empty,
EvaluatedInputs = inputs?.ToImmutableDictionary() ?? ImmutableDictionary<string, object?>.Empty,
EvaluatedAt = evaluatedAt ?? DateTimeOffset.UtcNow,
EvaluatedAt = evaluatedAt,
PolicyVersion = policyVersion,
CorrelationId = correlationId
};
@@ -229,23 +229,22 @@ public sealed record PolicyExplanationRecord(
/// <param name="policyId">The policy ID.</param>
/// <param name="tenantId">Optional tenant identifier.</param>
/// <param name="actor">Optional actor who triggered the evaluation.</param>
/// <param name="recordId">Optional record ID for deterministic testing. If null, generates a new GUID.</param>
/// <param name="evaluatedAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
/// <param name="recordId">Record ID for determinism. Required.</param>
/// <param name="evaluatedAt">Timestamp for the evaluation. Required for determinism.</param>
public static PolicyExplanationRecord FromExplanation(
PolicyExplanation explanation,
string policyId,
string recordId,
DateTimeOffset evaluatedAt,
string? tenantId = null,
string? actor = null,
string? recordId = null,
DateTimeOffset? evaluatedAt = null)
string? actor = null)
{
var id = recordId ?? $"pexp-{Guid.NewGuid():N}";
var ruleHitsJson = System.Text.Json.JsonSerializer.Serialize(explanation.RuleHits);
var inputsJson = System.Text.Json.JsonSerializer.Serialize(explanation.EvaluatedInputs);
var treeJson = System.Text.Json.JsonSerializer.Serialize(explanation.Nodes);
return new PolicyExplanationRecord(
Id: id,
Id: recordId,
FindingId: explanation.FindingId,
PolicyId: policyId,
PolicyVersion: explanation.PolicyVersion ?? "unknown",
@@ -254,7 +253,7 @@ public sealed record PolicyExplanationRecord(
RuleHitsJson: ruleHitsJson,
InputsJson: inputsJson,
ExplanationTreeJson: treeJson,
EvaluatedAt: explanation.EvaluatedAt ?? evaluatedAt ?? DateTimeOffset.UtcNow,
EvaluatedAt: explanation.EvaluatedAt ?? evaluatedAt,
CorrelationId: explanation.CorrelationId,
TenantId: tenantId,
Actor: actor);

View File

@@ -117,17 +117,17 @@ public sealed class ProofLedger
/// <summary>
/// Serialize the ledger to JSON.
/// </summary>
/// <param name="createdAtUtc">The timestamp for the ledger creation.</param>
/// <param name="options">Optional JSON serializer options.</param>
/// <param name="createdAtUtc">Optional timestamp for deterministic testing. If null, uses current time.</param>
/// <returns>The JSON representation of the ledger.</returns>
public string ToJson(JsonSerializerOptions? options = null, DateTimeOffset? createdAtUtc = null)
public string ToJson(DateTimeOffset createdAtUtc, JsonSerializerOptions? options = null)
{
lock (_lock)
{
var payload = new ProofLedgerPayload(
Nodes: [.. _nodes],
RootHash: RootHash(),
CreatedAtUtc: createdAtUtc ?? DateTimeOffset.UtcNow);
CreatedAtUtc: createdAtUtc);
return JsonSerializer.Serialize(payload, options ?? DefaultJsonOptions);
}

View File

@@ -326,7 +326,7 @@ public sealed class ScoreAttestationBuilder
/// <param name="breakdown">The score breakdown.</param>
/// <param name="policy">The scoring policy reference.</param>
/// <param name="inputs">The scoring inputs.</param>
/// <param name="scoredAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
/// <param name="scoredAt">The timestamp when scoring occurred.</param>
public static ScoreAttestationBuilder Create(
string subjectDigest,
int overallScore,
@@ -334,11 +334,11 @@ public sealed class ScoreAttestationBuilder
ScoreBreakdown breakdown,
ScoringPolicyRef policy,
ScoringInputs inputs,
DateTimeOffset? scoredAt = null)
DateTimeOffset scoredAt)
{
return new ScoreAttestationBuilder(new ScoreAttestationStatement
{
ScoredAt = scoredAt ?? DateTimeOffset.UtcNow,
ScoredAt = scoredAt,
SubjectDigest = subjectDigest,
OverallScore = overallScore,
Confidence = confidence,

View File

@@ -348,14 +348,14 @@ public sealed class ScoringRulesSnapshotBuilder
/// </summary>
/// <param name="id">The snapshot ID.</param>
/// <param name="version">The snapshot version.</param>
/// <param name="createdAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
public static ScoringRulesSnapshotBuilder Create(string id, int version, DateTimeOffset? createdAt = null)
/// <param name="createdAt">The timestamp for the snapshot creation.</param>
public static ScoringRulesSnapshotBuilder Create(string id, int version, DateTimeOffset createdAt)
{
return new ScoringRulesSnapshotBuilder(new ScoringRulesSnapshot
{
Id = id,
Version = version,
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
CreatedAt = createdAt,
Digest = "", // Will be computed on build
Weights = new ScoringWeights(),
Thresholds = new GradeThresholds(),

View File

@@ -183,11 +183,11 @@ public sealed class CsafVexNormalizer : IVexNormalizer
public Claim NormalizeStatement(
Subject subject,
CsafProductStatus status,
DateTimeOffset issuedAt,
CsafFlagLabel flag = CsafFlagLabel.None,
string? remediation = null,
Principal? principal = null,
TrustLabel? trustLabel = null,
DateTimeOffset? issuedAt = null)
TrustLabel? trustLabel = null)
{
var assertions = new List<AtomAssertion>();
@@ -221,7 +221,7 @@ public sealed class CsafVexNormalizer : IVexNormalizer
Issuer = principal ?? Principal.Unknown,
Assertions = assertions,
TrustLabel = trustLabel,
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
Time = new ClaimTimeInfo { IssuedAt = issuedAt },
};
}
}

View File

@@ -236,11 +236,11 @@ public sealed record PolicyBundle
/// Checks if a principal is trusted for a given scope.
/// </summary>
/// <param name="principal">The principal to check.</param>
/// <param name="asOf">Timestamp for trust evaluation. Allows deterministic testing.</param>
/// <param name="requiredScope">Optional required authority scope.</param>
/// <param name="asOf">Optional timestamp for deterministic testing. If null, uses current time.</param>
public bool IsTrusted(Principal principal, AuthorityScope? requiredScope = null, DateTimeOffset? asOf = null)
public bool IsTrusted(Principal principal, DateTimeOffset asOf, AuthorityScope? requiredScope = null)
{
var now = asOf ?? DateTimeOffset.UtcNow;
var now = asOf;
foreach (var root in TrustRoots)
{
@@ -261,10 +261,10 @@ public sealed record PolicyBundle
/// Gets the maximum assurance level for a principal.
/// </summary>
/// <param name="principal">The principal to check.</param>
/// <param name="asOf">Optional timestamp for deterministic testing. If null, uses current time.</param>
public AssuranceLevel? GetMaxAssurance(Principal principal, DateTimeOffset? asOf = null)
/// <param name="asOf">Timestamp for trust evaluation. Allows deterministic testing.</param>
public AssuranceLevel? GetMaxAssurance(Principal principal, DateTimeOffset asOf)
{
var now = asOf ?? DateTimeOffset.UtcNow;
var now = asOf;
foreach (var root in TrustRoots)
{

View File

@@ -42,7 +42,7 @@ public sealed record ProofInput
/// <summary>
/// Timestamp when the input was ingested.
/// </summary>
public DateTimeOffset IngestedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset IngestedAt { get; init; }
}
/// <summary>
@@ -161,7 +161,7 @@ public sealed record ProofBundle
/// <summary>
/// Timestamp when the proof bundle was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// The policy bundle used for evaluation.

View File

@@ -80,69 +80,74 @@ public sealed class ExceptionObjectTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExceptionObject_IsEffective_WhenActiveAndNotExpired_ShouldBeTrue()
public void ExceptionObject_IsEffectiveAt_WhenActiveAndNotExpired_ShouldBeTrue()
{
// Arrange
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateException(
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
expiresAt: referenceTime.AddDays(30));
// Act & Assert
exception.IsEffective.Should().BeTrue();
exception.HasExpired.Should().BeFalse();
exception.IsEffectiveAt(referenceTime).Should().BeTrue();
exception.HasExpiredAt(referenceTime).Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExceptionObject_IsEffective_WhenActiveButExpired_ShouldBeFalse()
public void ExceptionObject_IsEffectiveAt_WhenActiveButExpired_ShouldBeFalse()
{
// Arrange
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateException(
status: ExceptionStatus.Active,
expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
expiresAt: referenceTime.AddDays(-1));
// Act & Assert
exception.IsEffective.Should().BeFalse();
exception.HasExpired.Should().BeTrue();
exception.IsEffectiveAt(referenceTime).Should().BeFalse();
exception.HasExpiredAt(referenceTime).Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExceptionObject_IsEffective_WhenProposed_ShouldBeFalse()
public void ExceptionObject_IsEffectiveAt_WhenProposed_ShouldBeFalse()
{
// Arrange
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateException(
status: ExceptionStatus.Proposed,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
expiresAt: referenceTime.AddDays(30));
// Act & Assert
exception.IsEffective.Should().BeFalse();
exception.IsEffectiveAt(referenceTime).Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExceptionObject_IsEffective_WhenRevoked_ShouldBeFalse()
public void ExceptionObject_IsEffectiveAt_WhenRevoked_ShouldBeFalse()
{
// Arrange
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateException(
status: ExceptionStatus.Revoked,
expiresAt: DateTimeOffset.UtcNow.AddDays(30));
expiresAt: referenceTime.AddDays(30));
// Act & Assert
exception.IsEffective.Should().BeFalse();
exception.IsEffectiveAt(referenceTime).Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ExceptionObject_IsEffective_WhenExpiredStatus_ShouldBeFalse()
public void ExceptionObject_IsEffectiveAt_WhenExpiredStatus_ShouldBeFalse()
{
// Arrange
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateException(
status: ExceptionStatus.Expired,
expiresAt: DateTimeOffset.UtcNow.AddDays(-1));
expiresAt: referenceTime.AddDays(-1));
// Act & Assert
exception.IsEffective.Should().BeFalse();
exception.IsEffectiveAt(referenceTime).Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]

View File

@@ -86,73 +86,79 @@ public sealed class ExceptionObjectTests
}
[Fact]
public void IsEffective_WhenActiveAndNotExpired_ReturnsTrue()
public void IsEffectiveAt_WhenActiveAndNotExpired_ReturnsTrue()
{
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateValidException() with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
ExpiresAt = referenceTime.AddDays(30)
};
Assert.True(exception.IsEffective);
Assert.True(exception.IsEffectiveAt(referenceTime));
}
[Fact]
public void IsEffective_WhenActiveButExpired_ReturnsFalse()
public void IsEffectiveAt_WhenActiveButExpired_ReturnsFalse()
{
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateValidException() with
{
Status = ExceptionStatus.Active,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
ExpiresAt = referenceTime.AddDays(-1)
};
Assert.False(exception.IsEffective);
Assert.False(exception.IsEffectiveAt(referenceTime));
}
[Fact]
public void IsEffective_WhenProposed_ReturnsFalse()
public void IsEffectiveAt_WhenProposed_ReturnsFalse()
{
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateValidException() with
{
Status = ExceptionStatus.Proposed,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
ExpiresAt = referenceTime.AddDays(30)
};
Assert.False(exception.IsEffective);
Assert.False(exception.IsEffectiveAt(referenceTime));
}
[Fact]
public void IsEffective_WhenRevoked_ReturnsFalse()
public void IsEffectiveAt_WhenRevoked_ReturnsFalse()
{
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateValidException() with
{
Status = ExceptionStatus.Revoked,
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
ExpiresAt = referenceTime.AddDays(30)
};
Assert.False(exception.IsEffective);
Assert.False(exception.IsEffectiveAt(referenceTime));
}
[Fact]
public void HasExpired_WhenPastExpiresAt_ReturnsTrue()
public void HasExpiredAt_WhenPastExpiresAt_ReturnsTrue()
{
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateValidException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1)
ExpiresAt = referenceTime.AddDays(-1)
};
Assert.True(exception.HasExpired);
Assert.True(exception.HasExpiredAt(referenceTime));
}
[Fact]
public void HasExpired_WhenBeforeExpiresAt_ReturnsFalse()
public void HasExpiredAt_WhenBeforeExpiresAt_ReturnsFalse()
{
var referenceTime = DateTimeOffset.UtcNow;
var exception = CreateValidException() with
{
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
ExpiresAt = referenceTime.AddDays(30)
};
Assert.False(exception.HasExpired);
Assert.False(exception.HasExpiredAt(referenceTime));
}
[Theory]