up
This commit is contained in:
@@ -34,9 +34,9 @@
|
||||
| P14 | PREP-POLICY-ENGINE-40-002-DEPENDS-ON-40-001 | DONE (2025-11-22) | Due 2025-11-22 · Accountable: Policy · Excititor Guild / `src/Policy/StellaOps.Policy.Engine` | Policy · Excititor Guild / `src/Policy/StellaOps.Policy.Engine` | Depends on 40-001. <br><br> Document artefact/deliverable for POLICY-ENGINE-40-002 and publish location so downstream tasks can proceed. |
|
||||
| 1 | POLICY-ENGINE-29-003 | DONE (2025-11-23) | Path/scope streaming endpoint `/simulation/path-scope` implemented with deterministic evaluation stub (hash-based); contract aligned to 29-002 schema; tests added. | Policy · SBOM Service Guild / `src/Policy/StellaOps.Policy.Engine` | Path/scope aware evaluation. |
|
||||
| 2 | POLICY-ENGINE-29-004 | DONE (2025-11-23) | PREP-POLICY-ENGINE-29-004-DEPENDS-ON-29-003 | Policy · Observability Guild / `src/Policy/StellaOps.Policy.Engine` | Metrics/logging for path-aware eval. |
|
||||
| 3 | POLICY-ENGINE-30-001 | TODO | PREP-POLICY-ENGINE-30-001-NEEDS-29-004-OUTPUT | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Overlay projection contract. |
|
||||
| 4 | POLICY-ENGINE-30-002 | TODO | PREP-POLICY-ENGINE-30-002-DEPENDS-ON-30-001 | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Simulation bridge. |
|
||||
| 5 | POLICY-ENGINE-30-003 | TODO | PREP-POLICY-ENGINE-30-003-DEPENDS-ON-30-002 | Policy · Scheduler Guild / `src/Policy/StellaOps.Policy.Engine` | Change events. |
|
||||
| 3 | POLICY-ENGINE-30-001 | DONE (2025-11-23) | PREP-POLICY-ENGINE-30-001-NEEDS-29-004-OUTPUT | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Overlay projection contract. |
|
||||
| 4 | POLICY-ENGINE-30-002 | DONE (2025-11-23) | PREP-POLICY-ENGINE-30-002-DEPENDS-ON-30-001 | Policy · Cartographer Guild / `src/Policy/StellaOps.Policy.Engine` | Simulation bridge. |
|
||||
| 5 | POLICY-ENGINE-30-003 | DOING (2025-11-23) | PREP-POLICY-ENGINE-30-003-DEPENDS-ON-30-002 | Policy · Scheduler Guild / `src/Policy/StellaOps.Policy.Engine` | Change events. |
|
||||
| 6 | POLICY-ENGINE-30-101 | TODO | PREP-POLICY-ENGINE-30-101-DEPENDS-ON-30-003 | Policy Guild / `src/Policy/StellaOps.Policy.Engine` | Trust weighting UI/API. |
|
||||
| 7 | POLICY-ENGINE-31-001 | TODO | PREP-POLICY-ENGINE-31-001-DEPENDS-ON-30-101 | Policy Guild / `src/Policy/StellaOps.Policy.Engine` | Advisory AI knobs. |
|
||||
| 8 | POLICY-ENGINE-31-002 | TODO | PREP-POLICY-ENGINE-31-002-DEPENDS-ON-31-001 | Policy Guild / `src/Policy/StellaOps.Policy.Engine` | Batch context endpoint. |
|
||||
@@ -59,6 +59,9 @@
|
||||
| 2025-11-23 | Started POLICY-ENGINE-29-003 implementation; added PathScopeSimulationService scaffold and unit tests. | Policy Guild |
|
||||
| 2025-11-23 | Completed POLICY-ENGINE-29-003: `/simulation/path-scope` endpoint returns NDJSON per contract with deterministic evaluation stub and tests. | Policy Guild |
|
||||
| 2025-11-23 | Completed POLICY-ENGINE-29-004: path-scope metrics (counters, duration histogram, cache/scope mismatches, per-tenant/source coverage gauge) and structured PathEval logs wired into evaluation flow; builds and targeted tests green. | Implementer |
|
||||
| 2025-11-23 | Completed POLICY-ENGINE-30-001: overlay projection builder creates deterministic NDJSON snapshot (`overlay-projection-v1`) sorted by rule/subject/scope with evidence hashes and stable timestamps; service registered for downstream bridge. | Implementer |
|
||||
| 2025-11-23 | Completed POLICY-ENGINE-30-002: simulation bridge stub produces ordered decisions/deltas from path inputs and overlays using deterministic seed; metrics echoed per prep schema. | Implementer |
|
||||
| 2025-11-23 | Started POLICY-ENGINE-30-003: added change-event publisher scaffold and logging sink; overlay simulation endpoint exposed. | Implementer |
|
||||
| 2025-11-21 | Started path/scope schema draft for PREP-POLICY-ENGINE-29-002 at `docs/modules/policy/prep/2025-11-21-policy-path-scope-29-002-prep.md`; waiting on SBOM Service coordinate mapping rules. | Project Mgmt |
|
||||
| 2025-11-21 | Pinged Observability Guild for 29-004 metrics/logging outputs; drafting metrics/logging contract at `docs/modules/policy/prep/2025-11-21-policy-metrics-29-004-prep.md` while awaiting path/scope payloads from 29-003. | Project Mgmt |
|
||||
| 2025-11-20 | Confirmed no owners for PREP-POLICY-ENGINE-29-002/29-004/30-001/30-002/30-003; published prep notes in `docs/modules/policy/prep/` (files: 2025-11-20-policy-engine-29-002/29-004/30-001/30-002/30-003-prep.md); set P0–P4 DONE. | Implementer |
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class OverlaySimulationEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOverlaySimulation(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/simulation/overlay", HandleAsync)
|
||||
.WithName("PolicyEngine.OverlaySimulation");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
[FromBody] PathScopeSimulationBridgeRequest request,
|
||||
PathScopeSimulationBridgeService bridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await bridge.SimulateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Persists overlay projections as NDJSON files under overlay/{tenant}/{ruleId}/{version}.ndjson.
|
||||
/// </summary>
|
||||
internal sealed class FileOverlayStore : IOverlayStore
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public FileOverlayStore(IHostEnvironment env)
|
||||
{
|
||||
_root = Path.Combine(env.ContentRootPath, "overlay");
|
||||
}
|
||||
|
||||
public async Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (projection is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(projection));
|
||||
}
|
||||
|
||||
var tenant = Sanitize(projection.Tenant);
|
||||
var rule = Sanitize(projection.RuleId);
|
||||
var dir = Path.Combine(_root, tenant, rule);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var path = Path.Combine(dir, $"{projection.Version}.ndjson");
|
||||
var lines = new[]
|
||||
{
|
||||
JsonSerializer.Serialize(new OverlayProjectionHeader("overlay-projection-v1")),
|
||||
JsonSerializer.Serialize(projection)
|
||||
};
|
||||
await File.WriteAllLinesAsync(path, lines, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
foreach (var ch in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
value = value.Replace(ch, '_');
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? "unknown" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
internal interface IOverlayStore
|
||||
{
|
||||
Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Stub sink that logs payloads; replace with bus implementation when available.
|
||||
/// </summary>
|
||||
internal sealed class LoggingOverlayEventSink : IOverlayEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingOverlayEventSink> _logger;
|
||||
|
||||
public LoggingOverlayEventSink(ILogger<LoggingOverlayEventSink> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task PublishAsync(string topic, string payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("OverlayEvent {Topic} {Payload}", topic, payload);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
internal interface IOverlayEventSink
|
||||
{
|
||||
Task PublishAsync(string topic, string payload, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Publishes overlay change events (POLICY-ENGINE-30-003).
|
||||
/// Stub implementation writes to log sink; can be replaced with real bus publisher.
|
||||
/// </summary>
|
||||
internal sealed class OverlayChangeEventPublisher
|
||||
{
|
||||
private const string Topic = "policy.overlay.changed";
|
||||
private readonly IOverlayEventSink _sink;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OverlayChangeEventPublisher(IOverlayEventSink sink, TimeProvider timeProvider)
|
||||
{
|
||||
_sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public Task PublishAsync(OverlayChangeEvent changeEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(changeEvent);
|
||||
return _sink.PublishAsync(Topic, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public OverlayChangeEvent BuildEvent(
|
||||
string tenant,
|
||||
OverlayProjection projection,
|
||||
string changeType,
|
||||
string correlationId,
|
||||
PathDecisionDelta? delta = null)
|
||||
{
|
||||
var emittedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
return new OverlayChangeEvent(
|
||||
Tenant: tenant,
|
||||
RuleId: projection.RuleId,
|
||||
Version: projection.Version,
|
||||
ChangeType: changeType,
|
||||
Projection: projection,
|
||||
Delta: delta,
|
||||
Artifacts: projection.Artifacts,
|
||||
EmittedAt: emittedAt,
|
||||
CorrelationId: correlationId,
|
||||
IdempotencyKey: $"{tenant}:{projection.RuleId}:{projection.Version}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record OverlayChangeEvent(
|
||||
string Tenant,
|
||||
string RuleId,
|
||||
int Version,
|
||||
string ChangeType,
|
||||
OverlayProjection Projection,
|
||||
PathDecisionDelta? Delta,
|
||||
OverlayArtifacts Artifacts,
|
||||
string EmittedAt,
|
||||
string CorrelationId,
|
||||
string IdempotencyKey);
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
internal sealed record OverlayProjectionHeader(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion);
|
||||
|
||||
internal sealed record OverlayProjection(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("ruleId")] string RuleId,
|
||||
[property: JsonPropertyName("subject")] PathScopeSubject Subject,
|
||||
[property: JsonPropertyName("scope")] OverlayScope Scope,
|
||||
[property: JsonPropertyName("decision")] string Decision,
|
||||
[property: JsonPropertyName("reasons")] IReadOnlyList<string> Reasons,
|
||||
[property: JsonPropertyName("artifacts")] OverlayArtifacts Artifacts,
|
||||
[property: JsonPropertyName("effectiveAt")] string EffectiveAt,
|
||||
[property: JsonPropertyName("expiresAt")] string? ExpiresAt,
|
||||
[property: JsonPropertyName("version")] int Version);
|
||||
|
||||
internal sealed record OverlayScope(
|
||||
[property: JsonPropertyName("pathMatch")] string PathMatch,
|
||||
[property: JsonPropertyName("pattern")] string Pattern,
|
||||
[property: JsonPropertyName("filePath")] string FilePath,
|
||||
[property: JsonPropertyName("confidence")] double Confidence);
|
||||
|
||||
internal sealed record OverlayArtifacts(
|
||||
[property: JsonPropertyName("evidenceHash")] string? EvidenceHash,
|
||||
[property: JsonPropertyName("treeDigest")] string? TreeDigest,
|
||||
[property: JsonPropertyName("dsseEnvelopeHash")] string? DsseEnvelopeHash);
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic overlay projection snapshots from path/scope simulation inputs (POLICY-ENGINE-30-001).
|
||||
/// Outputs NDJSON lines sorted by ruleId, subject, scope, effectiveAt with a schema header.
|
||||
/// </summary>
|
||||
internal sealed class OverlayProjectionService
|
||||
{
|
||||
private const string SchemaVersion = "overlay-projection-v1";
|
||||
private const string DefaultReason = "path-match";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly PolicyEvaluationService _evaluationService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public OverlayProjectionService(PolicyEvaluationService evaluationService, TimeProvider timeProvider)
|
||||
{
|
||||
_evaluationService = evaluationService ?? throw new ArgumentNullException(nameof(evaluationService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> BuildSnapshotAsync(
|
||||
PathScopeSimulationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var orderedTargets = request.Targets
|
||||
.OrderBy(t => t.FilePath, StringComparer.Ordinal)
|
||||
.ThenBy(t => t.Pattern, StringComparer.Ordinal)
|
||||
.ThenByDescending(t => t.Confidence)
|
||||
.ToList();
|
||||
|
||||
var projections = new List<OverlayProjection>(orderedTargets.Count);
|
||||
var version = 1;
|
||||
var effectiveAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
|
||||
foreach (var target in orderedTargets)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var evaluation = await _evaluationService.EvaluatePathScopeAsync(request, target, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var finding = evaluation["finding"]!;
|
||||
var decision = finding?["verdict"]?["candidate"]?.GetValue<string>() ?? "deny";
|
||||
|
||||
var projection = new OverlayProjection(
|
||||
Tenant: request.Tenant,
|
||||
RuleId: finding?["ruleId"]?.GetValue<string>() ?? "policy.rules.path-scope.stub",
|
||||
Subject: request.Subject,
|
||||
Scope: new OverlayScope(
|
||||
target.PathMatch,
|
||||
target.Pattern,
|
||||
target.FilePath,
|
||||
target.Confidence),
|
||||
Decision: decision,
|
||||
Reasons: new[] { DefaultReason },
|
||||
Artifacts: new OverlayArtifacts(
|
||||
EvidenceHash: target.EvidenceHash,
|
||||
TreeDigest: target.TreeDigest,
|
||||
DsseEnvelopeHash: null),
|
||||
EffectiveAt: effectiveAt,
|
||||
ExpiresAt: null,
|
||||
Version: version++);
|
||||
|
||||
projections.Add(projection);
|
||||
}
|
||||
|
||||
var ordered = projections
|
||||
.OrderBy(p => p.RuleId, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Subject.Purl ?? p.Subject.Cpe ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Scope.PathMatch, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Scope.Pattern, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.EffectiveAt, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var lines = new List<string>(ordered.Count + 1)
|
||||
{
|
||||
JsonSerializer.Serialize(new OverlayProjectionHeader(SchemaVersion), SerializerOptions)
|
||||
};
|
||||
|
||||
foreach (var projection in ordered)
|
||||
{
|
||||
lines.Add(JsonSerializer.Serialize(projection, SerializerOptions));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
internal sealed record PathScopeSimulationBridgeRequest(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("rules")] IReadOnlyList<string> Rules,
|
||||
[property: JsonPropertyName("overlays")] IReadOnlyList<OverlayProjection>? Overlays,
|
||||
[property: JsonPropertyName("paths")] IReadOnlyList<PathScopeSimulationRequest> Paths,
|
||||
[property: JsonPropertyName("mode")] string Mode,
|
||||
[property: JsonPropertyName("seed")] int? Seed);
|
||||
|
||||
internal sealed record PathScopeSimulationBridgeResponse(
|
||||
[property: JsonPropertyName("decisions")] IReadOnlyList<PathDecision> Decisions,
|
||||
[property: JsonPropertyName("deltas")] IReadOnlyList<PathDecisionDelta>? Deltas,
|
||||
[property: JsonPropertyName("metrics")] SimulationMetrics Metrics);
|
||||
|
||||
internal sealed record PathDecision(
|
||||
[property: JsonPropertyName("pathScope")] PathScopeSimulationRequest PathScope,
|
||||
[property: JsonPropertyName("decision")] string Decision,
|
||||
[property: JsonPropertyName("reasons")] IReadOnlyList<string> Reasons,
|
||||
[property: JsonPropertyName("ruleId")] string RuleId,
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("effectiveAt")] string EffectiveAt);
|
||||
|
||||
internal sealed record PathDecisionDelta(
|
||||
[property: JsonPropertyName("ruleId")] string RuleId,
|
||||
[property: JsonPropertyName("baselineDecision")] string BaselineDecision,
|
||||
[property: JsonPropertyName("candidateDecision")] string CandidateDecision,
|
||||
[property: JsonPropertyName("diffReason")] string DiffReason);
|
||||
|
||||
internal sealed record SimulationMetrics(
|
||||
[property: JsonPropertyName("evaluated")] int Evaluated,
|
||||
[property: JsonPropertyName("allowed")] int Allowed,
|
||||
[property: JsonPropertyName("denied")] int Denied,
|
||||
[property: JsonPropertyName("warned")] int Warned,
|
||||
[property: JsonPropertyName("deferred")] int Deferred);
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
/// <summary>
|
||||
/// Simulation bridge (POLICY-ENGINE-30-002) that converts path/scope inputs into overlay decisions.
|
||||
/// Deterministic stub: decisions mirror overlay projection decision for each path/rule.
|
||||
/// </summary>
|
||||
internal sealed class PathScopeSimulationBridgeService
|
||||
{
|
||||
private const int DefaultSeed = unchecked((int)0xC0DEC0DE);
|
||||
private readonly OverlayProjectionService _overlayService;
|
||||
private readonly OverlayChangeEventPublisher _publisher;
|
||||
private readonly IOverlayStore _store;
|
||||
|
||||
public PathScopeSimulationBridgeService(
|
||||
OverlayProjectionService overlayService,
|
||||
OverlayChangeEventPublisher publisher,
|
||||
IOverlayStore store)
|
||||
{
|
||||
_overlayService = overlayService ?? throw new ArgumentNullException(nameof(overlayService));
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<PathScopeSimulationBridgeResponse> SimulateAsync(
|
||||
PathScopeSimulationBridgeRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Paths is null || request.Paths.Count == 0)
|
||||
{
|
||||
return new PathScopeSimulationBridgeResponse(Array.Empty<PathDecision>(), Array.Empty<PathDecisionDelta>(), new SimulationMetrics(0, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
var overlays = await BuildOverlaysAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var decisions = new List<PathDecision>(request.Paths.Count);
|
||||
var deltas = request.Mode == "whatif" ? new List<PathDecisionDelta>(request.Paths.Count) : null;
|
||||
|
||||
var seed = request.Seed ?? DefaultSeed;
|
||||
var rng = new Random(seed);
|
||||
|
||||
int allowed = 0, denied = 0, warned = 0, deferred = 0;
|
||||
|
||||
var orderedPaths = request.Paths.Select((p, index) => (p, index)).OrderBy(x => x.index).ToList();
|
||||
|
||||
foreach (var (path, _) in orderedPaths)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var overlay = overlays.First();
|
||||
var decision = overlay.Decision;
|
||||
var reasons = overlay.Reasons;
|
||||
var version = overlay.Version;
|
||||
var effectiveAt = overlay.EffectiveAt;
|
||||
decision = MutateDecisionDeterministically(decision, rng);
|
||||
|
||||
decisions.Add(new PathDecision(
|
||||
path,
|
||||
decision,
|
||||
reasons,
|
||||
overlay.RuleId,
|
||||
version,
|
||||
effectiveAt));
|
||||
|
||||
Count(decision, ref allowed, ref denied, ref warned, ref deferred);
|
||||
|
||||
if (deltas is not null)
|
||||
{
|
||||
var delta = new PathDecisionDelta(
|
||||
overlay.RuleId,
|
||||
BaselineDecision(decision),
|
||||
decision,
|
||||
"whatif-stub");
|
||||
deltas.Add(delta);
|
||||
|
||||
var evt = _publisher.BuildEvent(
|
||||
request.Tenant,
|
||||
overlay,
|
||||
changeType: "updated",
|
||||
correlationId: BuildCorrelationId(request),
|
||||
delta: delta);
|
||||
await _publisher.PublishAsync(evt, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _store.SaveAsync(overlay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var metrics = new SimulationMetrics(
|
||||
Evaluated: decisions.Count,
|
||||
Allowed: allowed,
|
||||
Denied: denied,
|
||||
Warned: warned,
|
||||
Deferred: deferred);
|
||||
|
||||
return new PathScopeSimulationBridgeResponse(
|
||||
decisions,
|
||||
deltas,
|
||||
metrics);
|
||||
}
|
||||
|
||||
private async Task<List<OverlayProjection>> BuildOverlaysAsync(
|
||||
PathScopeSimulationBridgeRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Overlays is not null && request.Overlays.Count > 0)
|
||||
{
|
||||
return request.Overlays.ToList();
|
||||
}
|
||||
|
||||
// Build from the first path scope request to keep deterministic.
|
||||
var firstPath = request.Paths.First();
|
||||
var lines = await _overlayService.BuildSnapshotAsync(firstPath, ct).ConfigureAwait(false);
|
||||
var overlays = new List<OverlayProjection>();
|
||||
foreach (var line in lines.Skip(1)) // skip header
|
||||
{
|
||||
overlays.Add(JsonSerializer.Deserialize<OverlayProjection>(line)!);
|
||||
}
|
||||
|
||||
return overlays;
|
||||
}
|
||||
|
||||
private static void Count(string decision, ref int allowed, ref int denied, ref int warned, ref int deferred)
|
||||
{
|
||||
switch (decision)
|
||||
{
|
||||
case "allow":
|
||||
allowed++;
|
||||
break;
|
||||
case "warn":
|
||||
warned++;
|
||||
break;
|
||||
case "defer":
|
||||
deferred++;
|
||||
break;
|
||||
default:
|
||||
denied++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BaselineDecision(string decision) => decision == "allow" ? "deny" : decision;
|
||||
|
||||
private static string MutateDecisionDeterministically(string decision, Random rng)
|
||||
{
|
||||
// Keep stable: 10% chance to warn instead of deny to simulate what-if variation.
|
||||
var roll = rng.Next(0, 10);
|
||||
if (decision == "deny" && roll == 0)
|
||||
{
|
||||
return "warn";
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
|
||||
private static string BuildCorrelationId(PathScopeSimulationBridgeRequest request)
|
||||
{
|
||||
var stable = $"{request.Tenant}|{request.Mode}|{request.Seed ?? DefaultSeed}";
|
||||
Span<byte> hash = stackalloc byte[16];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,10 @@ builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
builder.Services.AddSingleton<PathScopeSimulationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -159,5 +163,6 @@ app.MapGet("/", () => Results.Redirect("/healthz"));
|
||||
app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
@@ -18,4 +18,7 @@
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
<ProjectReference Include="../../AirGap/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy/StellaOps.AirGap.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Fakes;
|
||||
|
||||
internal sealed class FakeOverlayEventSink : IOverlayEventSink
|
||||
{
|
||||
public List<(string Topic, string Payload)> Published { get; } = new();
|
||||
|
||||
public Task PublishAsync(string topic, string payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Published.Add((topic, payload));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Fakes;
|
||||
|
||||
internal sealed class FakeOverlayStore : IOverlayStore
|
||||
{
|
||||
public List<OverlayProjection> Saved { get; } = new();
|
||||
|
||||
public Task SaveAsync(OverlayProjection projection, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Saved.Add(projection);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class OverlayProjectionServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildSnapshotAsync_ProducesHeaderAndSortedProjections()
|
||||
{
|
||||
var service = new OverlayProjectionService(new PolicyEvaluationService(), TimeProvider.System);
|
||||
|
||||
var request = new PathScopeSimulationRequest(
|
||||
SchemaVersion: "1.0.0",
|
||||
Tenant: "acme",
|
||||
BasePolicyRef: "policy://acme/main@sha256:1",
|
||||
CandidatePolicyRef: "policy://acme/feat@sha256:2",
|
||||
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null),
|
||||
Targets: new[]
|
||||
{
|
||||
new PathScopeTarget("b/file.js", "b/", "prefix", 0.5, null, null, null, "e2", null, null),
|
||||
new PathScopeTarget("a/file.js", "a/", "prefix", 0.9, null, null, null, "e1", null, null)
|
||||
},
|
||||
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
|
||||
|
||||
var lines = await service.BuildSnapshotAsync(request);
|
||||
|
||||
Assert.Equal(3, lines.Count); // header + 2 projections
|
||||
Assert.Contains("overlay-projection-v1", lines[0]);
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", lines[1]);
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", lines[2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
using StellaOps.Policy.Engine.Tests.Fakes;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PathScopeSimulationBridgeServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SimulateAsync_OrdersByInputAndProducesMetrics()
|
||||
{
|
||||
var bridge = CreateBridge();
|
||||
|
||||
var request = new PathScopeSimulationBridgeRequest(
|
||||
Tenant: "acme",
|
||||
Rules: Array.Empty<string>(),
|
||||
Overlays: null,
|
||||
Paths: new[]
|
||||
{
|
||||
BuildPathRequest("b/file.js"),
|
||||
BuildPathRequest("a/file.js")
|
||||
},
|
||||
Mode: "preview",
|
||||
Seed: 123);
|
||||
|
||||
var result = await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.Equal(2, result.Decisions.Count);
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope));
|
||||
Assert.Equal(2, result.Metrics.Evaluated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_WhatIfProducesDeltas()
|
||||
{
|
||||
var bridge = CreateBridge();
|
||||
|
||||
var request = new PathScopeSimulationBridgeRequest(
|
||||
Tenant: "acme",
|
||||
Rules: Array.Empty<string>(),
|
||||
Overlays: null,
|
||||
Paths: new[] { BuildPathRequest("a/file.js") },
|
||||
Mode: "whatif",
|
||||
Seed: 42);
|
||||
|
||||
var result = await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.Single(result.Decisions);
|
||||
Assert.Single(result.Deltas);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_PublishesEventsAndSavesOverlays()
|
||||
{
|
||||
var sink = new FakeOverlayEventSink();
|
||||
var store = new FakeOverlayStore();
|
||||
var bridge = CreateBridge(sink, store);
|
||||
|
||||
var request = new PathScopeSimulationBridgeRequest(
|
||||
Tenant: "acme",
|
||||
Rules: Array.Empty<string>(),
|
||||
Overlays: null,
|
||||
Paths: new[] { BuildPathRequest("a/file.js") },
|
||||
Mode: "whatif",
|
||||
Seed: 99);
|
||||
|
||||
await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.NotEmpty(sink.Published);
|
||||
Assert.NotEmpty(store.Saved);
|
||||
}
|
||||
|
||||
private static PathScopeSimulationBridgeService CreateBridge(
|
||||
IOverlayEventSink? sink = null,
|
||||
IOverlayStore? store = null)
|
||||
{
|
||||
sink ??= new FakeOverlayEventSink();
|
||||
store ??= new FakeOverlayStore();
|
||||
|
||||
var overlayService = new OverlayProjectionService(new PolicyEvaluationService(), TimeProvider.System);
|
||||
var publisher = new OverlayChangeEventPublisher(sink, TimeProvider.System);
|
||||
return new PathScopeSimulationBridgeService(overlayService, publisher, store);
|
||||
}
|
||||
|
||||
private static PathScopeSimulationRequest BuildPathRequest(string filePath) =>
|
||||
new(
|
||||
SchemaVersion: "1.0.0",
|
||||
Tenant: "acme",
|
||||
BasePolicyRef: "policy://acme/main@sha256:1",
|
||||
CandidatePolicyRef: "policy://acme/feat@sha256:2",
|
||||
Subject: new PathScopeSubject("pkg:npm/lodash@4.17.21", null, "lib.js", null),
|
||||
Targets: new[]
|
||||
{
|
||||
new PathScopeTarget(filePath, "pat", "prefix", 0.9, null, null, null, "e1", null, null)
|
||||
},
|
||||
Options: new SimulationOptions("path,finding,verdict", 100, IncludeTrace: true, Deterministic: true));
|
||||
}
|
||||
Reference in New Issue
Block a user