DET-004: Refactor Policy library for determinism - Gates, Snapshots, TrustLattice, Scoring, Explanation
- VexProofGate: Inject TimeProvider for proof age validation - SnapshotBuilder: Inject TimeProvider for WithVex/WithSbom/WithReachability/Build - CsafVexNormalizer, OpenVexNormalizer, VexNormalizers: Add optional issuedAt parameter - TrustLatticeEngine.ClaimBuilder: Add optional issuedAt parameter to Build - PolicyBundle: Add asOf parameter to IsTrusted and GetMaxAssurance - ProofLedger: Add createdAtUtc parameter to ToJson - ScoreAttestationBuilder: Add scoredAt parameter to Create - ScoringRulesSnapshotBuilder: Add createdAt parameter to Create - TrustSourceWeightService: Inject TimeProvider for stale data calculation - PolicyExplanation.Create: Add evaluatedAt parameter - PolicyExplanationRecord.FromExplanation: Add recordId and evaluatedAt parameters - PolicyPreviewService: Inject TimeProvider for snapshot creation - PolicySnapshotStore: Inject IGuidProvider for audit entry ID generation
This commit is contained in:
@@ -104,6 +104,7 @@ public sealed record VexProofGateContext
|
||||
public sealed class VexProofGate : IPolicyGate
|
||||
{
|
||||
private readonly VexProofGateOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
// Confidence tier ordering for comparison
|
||||
private static readonly IReadOnlyDictionary<string, int> ConfidenceTierOrder =
|
||||
@@ -114,9 +115,10 @@ public sealed class VexProofGate : IPolicyGate
|
||||
["high"] = 3,
|
||||
};
|
||||
|
||||
public VexProofGate(VexProofGateOptions? options = null)
|
||||
public VexProofGate(VexProofGateOptions? options = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? new VexProofGateOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<GateResult> EvaluateAsync(
|
||||
@@ -207,7 +209,7 @@ public sealed class VexProofGate : IPolicyGate
|
||||
// Validate proof age
|
||||
if (_options.MaxProofAgeHours >= 0 && proofContext.ProofComputedAt.HasValue)
|
||||
{
|
||||
var proofAge = DateTimeOffset.UtcNow - proofContext.ProofComputedAt.Value;
|
||||
var proofAge = _timeProvider.GetUtcNow() - proofContext.ProofComputedAt.Value;
|
||||
details["proofAgeHours"] = proofAge.TotalHours;
|
||||
details["maxProofAgeHours"] = _options.MaxProofAgeHours;
|
||||
|
||||
|
||||
@@ -61,6 +61,16 @@ public sealed record PolicyExplanation(
|
||||
/// <summary>
|
||||
/// Creates an explanation with full context for persistence.
|
||||
/// </summary>
|
||||
/// <param name="findingId">The finding ID.</param>
|
||||
/// <param name="decision">The policy decision.</param>
|
||||
/// <param name="ruleName">The rule name.</param>
|
||||
/// <param name="reason">The reason for the decision.</param>
|
||||
/// <param name="nodes">The explanation nodes.</param>
|
||||
/// <param name="ruleHits">Optional rule hits.</param>
|
||||
/// <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>
|
||||
public static PolicyExplanation Create(
|
||||
string findingId,
|
||||
PolicyVerdictStatus decision,
|
||||
@@ -70,12 +80,13 @@ public sealed record PolicyExplanation(
|
||||
IEnumerable<RuleHit>? ruleHits = null,
|
||||
IDictionary<string, object?>? inputs = null,
|
||||
string? policyVersion = null,
|
||||
string? correlationId = null) =>
|
||||
string? correlationId = null,
|
||||
DateTimeOffset? evaluatedAt = null) =>
|
||||
new(findingId, decision, ruleName, reason, nodes.ToImmutableArray())
|
||||
{
|
||||
RuleHits = ruleHits?.ToImmutableArray() ?? ImmutableArray<RuleHit>.Empty,
|
||||
EvaluatedInputs = inputs?.ToImmutableDictionary() ?? ImmutableDictionary<string, object?>.Empty,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
EvaluatedAt = evaluatedAt ?? DateTimeOffset.UtcNow,
|
||||
PolicyVersion = policyVersion,
|
||||
CorrelationId = correlationId
|
||||
};
|
||||
@@ -214,13 +225,21 @@ public sealed record PolicyExplanationRecord(
|
||||
/// <summary>
|
||||
/// Creates a persistence record from an explanation.
|
||||
/// </summary>
|
||||
/// <param name="explanation">The explanation to convert.</param>
|
||||
/// <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>
|
||||
public static PolicyExplanationRecord FromExplanation(
|
||||
PolicyExplanation explanation,
|
||||
string policyId,
|
||||
string? tenantId = null,
|
||||
string? actor = null)
|
||||
string? actor = null,
|
||||
string? recordId = null,
|
||||
DateTimeOffset? evaluatedAt = null)
|
||||
{
|
||||
var id = $"pexp-{Guid.NewGuid():N}";
|
||||
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);
|
||||
@@ -235,7 +254,7 @@ public sealed record PolicyExplanationRecord(
|
||||
RuleHitsJson: ruleHitsJson,
|
||||
InputsJson: inputsJson,
|
||||
ExplanationTreeJson: treeJson,
|
||||
EvaluatedAt: explanation.EvaluatedAt ?? DateTimeOffset.UtcNow,
|
||||
EvaluatedAt: explanation.EvaluatedAt ?? evaluatedAt ?? DateTimeOffset.UtcNow,
|
||||
CorrelationId: explanation.CorrelationId,
|
||||
TenantId: tenantId,
|
||||
Actor: actor);
|
||||
|
||||
@@ -13,11 +13,16 @@ public sealed class PolicyPreviewService
|
||||
{
|
||||
private readonly PolicySnapshotStore _snapshotStore;
|
||||
private readonly ILogger<PolicyPreviewService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
|
||||
public PolicyPreviewService(
|
||||
PolicySnapshotStore snapshotStore,
|
||||
ILogger<PolicyPreviewService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
|
||||
@@ -59,7 +64,7 @@ public sealed class PolicyPreviewService
|
||||
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
|
||||
request.SnapshotOverride?.RevisionId ?? "preview",
|
||||
digest,
|
||||
DateTimeOffset.UtcNow,
|
||||
_timeProvider.GetUtcNow(),
|
||||
request.ProposedPolicy.Actor,
|
||||
request.ProposedPolicy.Format,
|
||||
binding.Document,
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
@@ -10,6 +11,7 @@ public sealed class PolicySnapshotStore
|
||||
private readonly IPolicySnapshotRepository _snapshotRepository;
|
||||
private readonly IPolicyAuditRepository _auditRepository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<PolicySnapshotStore> _logger;
|
||||
private readonly SemaphoreSlim _mutex = new(1, 1);
|
||||
|
||||
@@ -17,11 +19,13 @@ public sealed class PolicySnapshotStore
|
||||
IPolicySnapshotRepository snapshotRepository,
|
||||
IPolicyAuditRepository auditRepository,
|
||||
TimeProvider? timeProvider,
|
||||
IGuidProvider? guidProvider,
|
||||
ILogger<PolicySnapshotStore> logger)
|
||||
{
|
||||
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? SystemGuidProvider.Instance;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
@@ -72,7 +76,7 @@ public sealed class PolicySnapshotStore
|
||||
|
||||
var auditMessage = content.Description ?? "Policy snapshot created";
|
||||
var auditEntry = new PolicyAuditEntry(
|
||||
Guid.NewGuid(),
|
||||
_guidProvider.NewGuid(),
|
||||
createdAt,
|
||||
"snapshot.created",
|
||||
revisionId,
|
||||
|
||||
@@ -118,15 +118,16 @@ public sealed class ProofLedger
|
||||
/// Serialize the ledger to JSON.
|
||||
/// </summary>
|
||||
/// <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)
|
||||
public string ToJson(JsonSerializerOptions? options = null, DateTimeOffset? createdAtUtc = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var payload = new ProofLedgerPayload(
|
||||
Nodes: [.. _nodes],
|
||||
RootHash: RootHash(),
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow);
|
||||
CreatedAtUtc: createdAtUtc ?? DateTimeOffset.UtcNow);
|
||||
|
||||
return JsonSerializer.Serialize(payload, options ?? DefaultJsonOptions);
|
||||
}
|
||||
|
||||
@@ -320,17 +320,25 @@ public sealed class ScoreAttestationBuilder
|
||||
/// <summary>
|
||||
/// Creates a new builder.
|
||||
/// </summary>
|
||||
/// <param name="subjectDigest">The subject digest.</param>
|
||||
/// <param name="overallScore">The overall score.</param>
|
||||
/// <param name="confidence">The confidence value.</param>
|
||||
/// <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>
|
||||
public static ScoreAttestationBuilder Create(
|
||||
string subjectDigest,
|
||||
int overallScore,
|
||||
double confidence,
|
||||
ScoreBreakdown breakdown,
|
||||
ScoringPolicyRef policy,
|
||||
ScoringInputs inputs)
|
||||
ScoringInputs inputs,
|
||||
DateTimeOffset? scoredAt = null)
|
||||
{
|
||||
return new ScoreAttestationBuilder(new ScoreAttestationStatement
|
||||
{
|
||||
ScoredAt = DateTimeOffset.UtcNow,
|
||||
ScoredAt = scoredAt ?? DateTimeOffset.UtcNow,
|
||||
SubjectDigest = subjectDigest,
|
||||
OverallScore = overallScore,
|
||||
Confidence = confidence,
|
||||
|
||||
@@ -346,13 +346,16 @@ public sealed class ScoringRulesSnapshotBuilder
|
||||
/// <summary>
|
||||
/// Creates a new builder with defaults.
|
||||
/// </summary>
|
||||
public static ScoringRulesSnapshotBuilder Create(string id, int version)
|
||||
/// <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)
|
||||
{
|
||||
return new ScoringRulesSnapshotBuilder(new ScoringRulesSnapshot
|
||||
{
|
||||
Id = id,
|
||||
Version = version,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = createdAt ?? DateTimeOffset.UtcNow,
|
||||
Digest = "", // Will be computed on build
|
||||
Weights = new ScoringWeights(),
|
||||
Thresholds = new GradeThresholds(),
|
||||
|
||||
@@ -281,10 +281,12 @@ public sealed record WeightedMergeResult
|
||||
public sealed class TrustSourceWeightService
|
||||
{
|
||||
private readonly TrustSourceWeightConfig _config;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TrustSourceWeightService(TrustSourceWeightConfig? config = null)
|
||||
public TrustSourceWeightService(TrustSourceWeightConfig? config = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_config = config ?? new TrustSourceWeightConfig();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -320,7 +322,7 @@ public sealed class TrustSourceWeightService
|
||||
// Penalty for stale data (>7 days old)
|
||||
if (source.FetchedAt.HasValue)
|
||||
{
|
||||
var age = DateTimeOffset.UtcNow - source.FetchedAt.Value;
|
||||
var age = _timeProvider.GetUtcNow() - source.FetchedAt.Value;
|
||||
if (age.TotalDays > 7)
|
||||
{
|
||||
weight *= 0.95;
|
||||
|
||||
@@ -18,10 +18,12 @@ public sealed class SnapshotBuilder
|
||||
private TrustBundleRef? _trust;
|
||||
private DeterminismProfile? _environment;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SnapshotBuilder(ICryptoHash cryptoHash)
|
||||
public SnapshotBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public SnapshotBuilder WithEngine(string name, string version, string commit)
|
||||
@@ -81,7 +83,7 @@ public sealed class SnapshotBuilder
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.Vex,
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
Epoch = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture),
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
@@ -94,7 +96,7 @@ public sealed class SnapshotBuilder
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.Sbom,
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
Epoch = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture),
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
@@ -107,7 +109,7 @@ public sealed class SnapshotBuilder
|
||||
{
|
||||
Name = name,
|
||||
Type = KnowledgeSourceTypes.Reachability,
|
||||
Epoch = DateTimeOffset.UtcNow.ToString("o", CultureInfo.InvariantCulture),
|
||||
Epoch = _timeProvider.GetUtcNow().ToString("o", CultureInfo.InvariantCulture),
|
||||
Digest = digest,
|
||||
Origin = origin
|
||||
});
|
||||
@@ -148,7 +150,7 @@ public sealed class SnapshotBuilder
|
||||
var manifest = new KnowledgeSnapshotManifest
|
||||
{
|
||||
SnapshotId = "", // Placeholder
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Engine = _engine,
|
||||
Plugins = _plugins.ToList(),
|
||||
Policy = _policy,
|
||||
|
||||
@@ -186,7 +186,8 @@ public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
CsafFlagLabel flag = CsafFlagLabel.None,
|
||||
string? remediation = null,
|
||||
Principal? principal = null,
|
||||
TrustLabel? trustLabel = null)
|
||||
TrustLabel? trustLabel = null,
|
||||
DateTimeOffset? issuedAt = null)
|
||||
{
|
||||
var assertions = new List<AtomAssertion>();
|
||||
|
||||
@@ -220,7 +221,7 @@ public sealed class CsafVexNormalizer : IVexNormalizer
|
||||
Issuer = principal ?? Principal.Unknown,
|
||||
Assertions = assertions,
|
||||
TrustLabel = trustLabel,
|
||||
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
string? actionStatement = null,
|
||||
string? impactStatement = null,
|
||||
Principal? principal = null,
|
||||
TrustLabel? trustLabel = null)
|
||||
TrustLabel? trustLabel = null,
|
||||
DateTimeOffset? issuedAt = null)
|
||||
{
|
||||
var assertions = new List<AtomAssertion>();
|
||||
|
||||
@@ -191,7 +192,7 @@ public sealed class OpenVexNormalizer : IVexNormalizer
|
||||
Issuer = principal ?? Principal.Unknown,
|
||||
Assertions = assertions,
|
||||
TrustLabel = trustLabel,
|
||||
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,9 +235,12 @@ public sealed record PolicyBundle
|
||||
/// <summary>
|
||||
/// Checks if a principal is trusted for a given scope.
|
||||
/// </summary>
|
||||
public bool IsTrusted(Principal principal, AuthorityScope? requiredScope = null)
|
||||
/// <param name="principal">The principal to check.</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)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = asOf ?? DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var root in TrustRoots)
|
||||
{
|
||||
@@ -257,9 +260,11 @@ public sealed record PolicyBundle
|
||||
/// <summary>
|
||||
/// Gets the maximum assurance level for a principal.
|
||||
/// </summary>
|
||||
public AssuranceLevel? GetMaxAssurance(Principal principal)
|
||||
/// <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)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = asOf ?? DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var root in TrustRoots)
|
||||
{
|
||||
|
||||
@@ -397,7 +397,8 @@ public sealed class TrustLatticeEngine
|
||||
/// <summary>
|
||||
/// Builds and ingests the claim.
|
||||
/// </summary>
|
||||
public Claim Build()
|
||||
/// <param name="issuedAt">Optional timestamp for deterministic testing. If null, uses current time.</param>
|
||||
public Claim Build(DateTimeOffset? issuedAt = null)
|
||||
{
|
||||
if (_subject is null)
|
||||
throw new InvalidOperationException("Subject is required.");
|
||||
@@ -409,7 +410,7 @@ public sealed class TrustLatticeEngine
|
||||
TrustLabel = _trustLabel,
|
||||
Assertions = _assertions,
|
||||
EvidenceRefs = _evidenceRefs,
|
||||
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
|
||||
};
|
||||
|
||||
return _engine.IngestClaim(claim);
|
||||
|
||||
@@ -270,6 +270,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
/// <param name="detail">Optional detail text.</param>
|
||||
/// <param name="principal">The principal making the assertion.</param>
|
||||
/// <param name="trustLabel">Optional trust label.</param>
|
||||
/// <param name="issuedAt">Optional timestamp for deterministic testing.</param>
|
||||
/// <returns>A normalized claim.</returns>
|
||||
public Claim NormalizeStatement(
|
||||
Subject subject,
|
||||
@@ -277,7 +278,8 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
CycloneDxJustification justification = CycloneDxJustification.None,
|
||||
string? detail = null,
|
||||
Principal? principal = null,
|
||||
TrustLabel? trustLabel = null)
|
||||
TrustLabel? trustLabel = null,
|
||||
DateTimeOffset? issuedAt = null)
|
||||
{
|
||||
var assertions = new List<AtomAssertion>();
|
||||
|
||||
@@ -312,7 +314,7 @@ public sealed class CycloneDxVexNormalizer : IVexNormalizer
|
||||
Issuer = principal ?? Principal.Unknown,
|
||||
Assertions = assertions,
|
||||
TrustLabel = trustLabel,
|
||||
Time = new ClaimTimeInfo { IssuedAt = DateTimeOffset.UtcNow },
|
||||
Time = new ClaimTimeInfo { IssuedAt = issuedAt ?? DateTimeOffset.UtcNow },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user