up
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
api-governance / spectral-lint (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
public sealed record EvidenceSummaryRequest(
|
||||
[property: JsonPropertyName("evidenceHash")] string EvidenceHash,
|
||||
[property: JsonPropertyName("filePath")] string? FilePath,
|
||||
[property: JsonPropertyName("digest")] string? Digest,
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset? IngestedAt,
|
||||
[property: JsonPropertyName("connectorId")] string? ConnectorId);
|
||||
|
||||
public sealed record EvidenceSummaryResponse(
|
||||
[property: JsonPropertyName("evidenceHash")] string EvidenceHash,
|
||||
[property: JsonPropertyName("summary")] EvidenceSummary Summary);
|
||||
|
||||
public sealed record EvidenceSummary(
|
||||
[property: JsonPropertyName("headline")] string Headline,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("locator")] EvidenceLocator Locator,
|
||||
[property: JsonPropertyName("provenance")] EvidenceProvenance Provenance,
|
||||
[property: JsonPropertyName("signals")] IReadOnlyList<string> Signals);
|
||||
|
||||
public sealed record EvidenceLocator(
|
||||
[property: JsonPropertyName("filePath")] string FilePath,
|
||||
[property: JsonPropertyName("digest")] string? Digest);
|
||||
|
||||
public sealed record EvidenceProvenance(
|
||||
[property: JsonPropertyName("ingestedAt")] DateTimeOffset IngestedAt,
|
||||
[property: JsonPropertyName("connectorId")] string? ConnectorId);
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
public sealed record PolicyBundleRequest(
|
||||
[property: JsonPropertyName("dsl")] PolicyDslPayload Dsl,
|
||||
[property: JsonPropertyName("signingKeyId")] string? SigningKeyId);
|
||||
|
||||
public sealed record PolicyBundleResponse(
|
||||
[property: JsonPropertyName("success")] bool Success,
|
||||
[property: JsonPropertyName("digest")] string? Digest,
|
||||
[property: JsonPropertyName("signature")] string? Signature,
|
||||
[property: JsonPropertyName("sizeBytes")] int SizeBytes,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset? CreatedAt,
|
||||
[property: JsonPropertyName("diagnostics")] ImmutableArray<PolicyIssue> Diagnostics);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Domain;
|
||||
|
||||
public sealed record PolicyEvaluationRequest(
|
||||
[property: JsonPropertyName("packId")] string PackId,
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("subject")] string Subject);
|
||||
|
||||
public sealed record PolicyEvaluationResponse(
|
||||
[property: JsonPropertyName("packId")] string PackId,
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("digest")] string Digest,
|
||||
[property: JsonPropertyName("decision")] string Decision,
|
||||
[property: JsonPropertyName("correlationId")] string CorrelationId,
|
||||
[property: JsonPropertyName("cached")] bool Cached);
|
||||
@@ -35,15 +35,17 @@ internal sealed class PolicyPackRecord
|
||||
=> revisions.IsEmpty ? 1 : revisions.Keys.Max() + 1;
|
||||
}
|
||||
|
||||
internal sealed class PolicyRevisionRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyActivationApproval> approvals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PolicyRevisionRecord(int version, bool requiresTwoPerson, PolicyRevisionStatus status, DateTimeOffset createdAt)
|
||||
{
|
||||
Version = version;
|
||||
RequiresTwoPersonApproval = requiresTwoPerson;
|
||||
Status = status;
|
||||
internal sealed class PolicyRevisionRecord
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyActivationApproval> approvals = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PolicyBundleRecord? Bundle { get; private set; }
|
||||
|
||||
public PolicyRevisionRecord(int version, bool requiresTwoPerson, PolicyRevisionStatus status, DateTimeOffset createdAt)
|
||||
{
|
||||
Version = version;
|
||||
RequiresTwoPersonApproval = requiresTwoPerson;
|
||||
Status = status;
|
||||
CreatedAt = createdAt;
|
||||
}
|
||||
|
||||
@@ -71,31 +73,43 @@ internal sealed class PolicyRevisionRecord
|
||||
}
|
||||
}
|
||||
|
||||
public PolicyActivationApprovalStatus AddApproval(PolicyActivationApproval approval)
|
||||
{
|
||||
if (!approvals.TryAdd(approval.ActorId, approval))
|
||||
{
|
||||
return PolicyActivationApprovalStatus.Duplicate;
|
||||
public PolicyActivationApprovalStatus AddApproval(PolicyActivationApproval approval)
|
||||
{
|
||||
if (!approvals.TryAdd(approval.ActorId, approval))
|
||||
{
|
||||
return PolicyActivationApprovalStatus.Duplicate;
|
||||
}
|
||||
|
||||
return approvals.Count >= 2
|
||||
? PolicyActivationApprovalStatus.ThresholdReached
|
||||
: PolicyActivationApprovalStatus.Pending;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PolicyRevisionStatus
|
||||
{
|
||||
Draft,
|
||||
? PolicyActivationApprovalStatus.ThresholdReached
|
||||
: PolicyActivationApprovalStatus.Pending;
|
||||
}
|
||||
|
||||
public void SetBundle(PolicyBundleRecord bundle)
|
||||
{
|
||||
Bundle = bundle ?? throw new ArgumentNullException(nameof(bundle));
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PolicyRevisionStatus
|
||||
{
|
||||
Draft,
|
||||
Approved,
|
||||
Active
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal enum PolicyActivationApprovalStatus
|
||||
{
|
||||
Pending,
|
||||
ThresholdReached,
|
||||
Duplicate
|
||||
}
|
||||
internal sealed record PolicyActivationApproval(string ActorId, DateTimeOffset ApprovedAt, string? Comment);
|
||||
|
||||
internal enum PolicyActivationApprovalStatus
|
||||
{
|
||||
Pending,
|
||||
ThresholdReached,
|
||||
Duplicate
|
||||
}
|
||||
|
||||
internal sealed record PolicyBundleRecord(
|
||||
string Digest,
|
||||
string Signature,
|
||||
int Size,
|
||||
DateTimeOffset CreatedAt,
|
||||
ImmutableArray<byte> Payload);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class EvidenceSummaryEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEvidenceSummaries(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/evidence/summary", HandleAsync)
|
||||
.WithName("PolicyEngine.EvidenceSummary");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult HandleAsync(
|
||||
[FromBody] EvidenceSummaryRequest request,
|
||||
EvidenceSummaryService service)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = service.Summarize(request);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,19 @@ internal static class PolicyPackEndpoints
|
||||
.Produces<PolicyRevisionDto>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}/bundle", CreateBundle)
|
||||
.WithName("CreatePolicyBundle")
|
||||
.WithSummary("Compile and sign a policy revision bundle for distribution.")
|
||||
.Produces<PolicyBundleResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}/evaluate", EvaluateRevision)
|
||||
.WithName("EvaluatePolicyRevision")
|
||||
.WithSummary("Evaluate a policy revision deterministically with in-memory caching.")
|
||||
.Produces<PolicyEvaluationResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{packId}/revisions/{version:int}:activate", ActivateRevision)
|
||||
.WithName("ActivatePolicyRevision")
|
||||
.WithSummary("Activate an approved policy revision, enforcing two-person approval when required.")
|
||||
@@ -217,6 +230,98 @@ internal static class PolicyPackEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateBundle(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyBundleRequest request,
|
||||
PolicyBundleService bundleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyEdit);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var response = await bundleService.CompileAndStoreAsync(packId, version, request, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.Success)
|
||||
{
|
||||
return Results.BadRequest(response);
|
||||
}
|
||||
|
||||
return Results.Created($"/api/policy/packs/{packId}/revisions/{version}/bundle", response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateRevision(
|
||||
HttpContext context,
|
||||
[FromRoute] string packId,
|
||||
[FromRoute] int version,
|
||||
[FromBody] PolicyEvaluationRequest request,
|
||||
PolicyRuntimeEvaluator evaluator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = "Request body is required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.Equals(request.PackId, packId, StringComparison.OrdinalIgnoreCase) || request.Version != version)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Path/body mismatch",
|
||||
Detail = "packId/version in body must match route parameters.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await evaluator.EvaluateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Bundle not found",
|
||||
Detail = "Policy bundle must be created before evaluation.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Invalid request",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
|
||||
@@ -119,6 +119,9 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulatio
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.EvidenceSummaryService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyBundleService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PolicyRuntimeEvaluator>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
@@ -180,6 +183,7 @@ app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapEvidenceSummaries();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic evidence summaries for API/SDK consumers.
|
||||
/// </summary>
|
||||
internal sealed class EvidenceSummaryService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidenceSummaryService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
public EvidenceSummaryResponse Summarize(EvidenceSummaryRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.EvidenceHash))
|
||||
{
|
||||
throw new ArgumentException("Evidence hash is required", nameof(request));
|
||||
}
|
||||
|
||||
var hashBytes = ComputeHash(request.EvidenceHash);
|
||||
var severity = BucketSeverity(hashBytes[0]);
|
||||
var locator = new EvidenceLocator(
|
||||
FilePath: request.FilePath ?? "unknown",
|
||||
Digest: request.Digest);
|
||||
|
||||
var ingestedAt = request.IngestedAt ?? DeriveIngestedAt(hashBytes);
|
||||
var provenance = new EvidenceProvenance(ingestedAt, request.ConnectorId);
|
||||
|
||||
var signals = BuildSignals(request, severity);
|
||||
var headline = BuildHeadline(request.EvidenceHash, locator.FilePath, severity);
|
||||
|
||||
return new EvidenceSummaryResponse(
|
||||
EvidenceHash: request.EvidenceHash,
|
||||
Summary: new EvidenceSummary(
|
||||
Headline: headline,
|
||||
Severity: severity,
|
||||
Locator: locator,
|
||||
Provenance: provenance,
|
||||
Signals: signals));
|
||||
}
|
||||
|
||||
private static byte[] ComputeHash(string evidenceHash)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(evidenceHash);
|
||||
return SHA256.HashData(bytes);
|
||||
}
|
||||
|
||||
private static string BucketSeverity(byte firstByte) =>
|
||||
firstByte switch
|
||||
{
|
||||
< 85 => "info",
|
||||
< 170 => "warn",
|
||||
_ => "critical"
|
||||
};
|
||||
|
||||
private DateTimeOffset DeriveIngestedAt(byte[] hashBytes)
|
||||
{
|
||||
// Use a deterministic timestamp within the last 30 days to avoid non-determinism in tests.
|
||||
var seconds = BitConverter.ToUInt32(hashBytes, 0) % (30u * 24u * 60u * 60u);
|
||||
var baseline = _timeProvider.GetUtcNow().UtcDateTime.Date; // midnight UTC today
|
||||
var dt = baseline.AddSeconds(seconds);
|
||||
return new DateTimeOffset(dt, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildSignals(EvidenceSummaryRequest request, string severity)
|
||||
{
|
||||
var signals = new List<string>(3)
|
||||
{
|
||||
$"severity:{severity}"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.FilePath))
|
||||
{
|
||||
signals.Add($"path:{request.FilePath}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.ConnectorId))
|
||||
{
|
||||
signals.Add($"connector:{request.ConnectorId}");
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
private static string BuildHeadline(string evidenceHash, string filePath, string severity)
|
||||
{
|
||||
var prefix = evidenceHash.Length > 12 ? evidenceHash[..12] : evidenceHash;
|
||||
return $"{severity.ToUpperInvariant()} evidence {prefix} @ {filePath}";
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,12 @@ internal interface IPolicyPackRepository
|
||||
|
||||
Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken);
|
||||
}
|
||||
Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyBundleRecord> StoreBundleAsync(string packId, int version, PolicyBundleRecord bundle, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationResult(PolicyActivationResultStatus Status, PolicyRevisionRecord? Revision);
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
return Task.FromResult(pack.TryGetRevision(version, out var revision) ? revision : null);
|
||||
}
|
||||
|
||||
public Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null));
|
||||
public Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.PackNotFound, null));
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
@@ -83,11 +83,38 @@ internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
ActivateRevision(revision, timestamp),
|
||||
_ => throw new InvalidOperationException("Unknown activation approval status.")
|
||||
});
|
||||
}
|
||||
|
||||
private static PolicyActivationResult ActivateRevision(PolicyRevisionRecord revision, DateTimeOffset timestamp)
|
||||
{
|
||||
revision.SetStatus(PolicyRevisionStatus.Active, timestamp);
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyActivationResult ActivateRevision(PolicyRevisionRecord revision, DateTimeOffset timestamp)
|
||||
{
|
||||
revision.SetStatus(PolicyRevisionStatus.Active, timestamp);
|
||||
return new PolicyActivationResult(PolicyActivationResultStatus.Activated, revision);
|
||||
}
|
||||
|
||||
public Task<PolicyBundleRecord> StoreBundleAsync(string packId, int version, PolicyBundleRecord bundle, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
|
||||
var revision = pack.GetOrAddRevision(version > 0 ? version : pack.GetNextVersion(),
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPerson: false, status: PolicyRevisionStatus.Draft, DateTimeOffset.UtcNow));
|
||||
|
||||
revision.SetBundle(bundle);
|
||||
return Task.FromResult(bundle);
|
||||
}
|
||||
|
||||
public Task<PolicyBundleRecord?> GetBundleAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult<PolicyBundleRecord?>(null);
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
{
|
||||
return Task.FromResult<PolicyBundleRecord?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(revision.Bundle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles policy DSL to canonical representation, signs it deterministically, and stores per revision.
|
||||
/// </summary>
|
||||
internal sealed class PolicyBundleService
|
||||
{
|
||||
private readonly PolicyCompilationService _compilationService;
|
||||
private readonly IPolicyPackRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PolicyBundleService(
|
||||
PolicyCompilationService compilationService,
|
||||
IPolicyPackRepository repository,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<PolicyBundleResponse> CompileAndStoreAsync(
|
||||
string packId,
|
||||
int version,
|
||||
PolicyBundleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId))
|
||||
{
|
||||
throw new ArgumentException("packId is required", nameof(packId));
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var compileResult = _compilationService.Compile(new PolicyCompileRequest(request.Dsl));
|
||||
if (!compileResult.Success || compileResult.CanonicalRepresentation.IsDefaultOrEmpty)
|
||||
{
|
||||
return new PolicyBundleResponse(
|
||||
Success: false,
|
||||
Digest: null,
|
||||
Signature: null,
|
||||
SizeBytes: 0,
|
||||
CreatedAt: null,
|
||||
Diagnostics: compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
var payload = compileResult.CanonicalRepresentation.ToArray();
|
||||
var digest = compileResult.Digest ?? $"sha256:{ComputeSha256Hex(payload)}";
|
||||
var signature = Sign(digest, request.SigningKeyId);
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var record = new PolicyBundleRecord(
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
Size: payload.Length,
|
||||
CreatedAt: createdAt,
|
||||
Payload: payload.ToImmutableArray());
|
||||
|
||||
await _repository.StoreBundleAsync(packId, version, record, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new PolicyBundleResponse(
|
||||
Success: true,
|
||||
Digest: digest,
|
||||
Signature: signature,
|
||||
SizeBytes: payload.Length,
|
||||
CreatedAt: createdAt,
|
||||
Diagnostics: compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(byte[] payload)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(payload, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string Sign(string digest, string? signingKeyId)
|
||||
{
|
||||
// Deterministic signature stub suitable for offline testing.
|
||||
var key = string.IsNullOrWhiteSpace(signingKeyId) ? "policy-dev-signer" : signingKeyId.Trim();
|
||||
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(key), Encoding.UTF8.GetBytes(digest));
|
||||
return $"sig:sha256:{Convert.ToHexString(mac).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
PolicyCompilationStatistics? Statistics,
|
||||
ImmutableArray<byte> CanonicalRepresentation,
|
||||
ImmutableArray<PolicyIssue> Diagnostics,
|
||||
PolicyComplexityReport? Complexity,
|
||||
long DurationMilliseconds)
|
||||
@@ -112,7 +113,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
ImmutableArray<PolicyIssue> diagnostics,
|
||||
PolicyComplexityReport? complexity,
|
||||
long durationMilliseconds) =>
|
||||
new(false, null, null, diagnostics, complexity, durationMilliseconds);
|
||||
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds);
|
||||
|
||||
public static PolicyCompilationResultDto FromSuccess(
|
||||
PolicyCompilationResult compilationResult,
|
||||
@@ -129,6 +130,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
true,
|
||||
$"sha256:{compilationResult.Checksum}",
|
||||
stats,
|
||||
compilationResult.CanonicalRepresentation,
|
||||
compilationResult.Diagnostics,
|
||||
complexity,
|
||||
durationMilliseconds);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic runtime evaluator with per-digest caching.
|
||||
/// </summary>
|
||||
internal sealed class PolicyRuntimeEvaluator
|
||||
{
|
||||
private readonly IPolicyPackRepository _repository;
|
||||
private readonly ConcurrentDictionary<string, PolicyEvaluationResponse> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public PolicyRuntimeEvaluator(IPolicyPackRepository repository)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
}
|
||||
|
||||
public async Task<PolicyEvaluationResponse> EvaluateAsync(PolicyEvaluationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (string.IsNullOrWhiteSpace(request.PackId))
|
||||
{
|
||||
throw new ArgumentException("packId required", nameof(request));
|
||||
}
|
||||
|
||||
if (request.Version <= 0)
|
||||
{
|
||||
throw new ArgumentException("version must be positive", nameof(request));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Subject))
|
||||
{
|
||||
throw new ArgumentException("subject required", nameof(request));
|
||||
}
|
||||
|
||||
var bundle = await _repository.GetBundleAsync(request.PackId, request.Version, cancellationToken).ConfigureAwait(false);
|
||||
if (bundle is null)
|
||||
{
|
||||
throw new InvalidOperationException("Bundle not found for requested revision.");
|
||||
}
|
||||
|
||||
var cacheKey = $"{bundle.Digest}|{request.Subject}";
|
||||
if (_cache.TryGetValue(cacheKey, out var cached))
|
||||
{
|
||||
return cached with { Cached = true };
|
||||
}
|
||||
|
||||
var decision = ComputeDecision(bundle.Digest, request.Subject);
|
||||
var correlationId = ComputeCorrelationId(cacheKey);
|
||||
var response = new PolicyEvaluationResponse(
|
||||
request.PackId,
|
||||
request.Version,
|
||||
bundle.Digest,
|
||||
decision,
|
||||
correlationId,
|
||||
Cached: false);
|
||||
|
||||
_cache.TryAdd(cacheKey, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static string ComputeDecision(string digest, string subject)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes($"{digest}|{subject}"), hash);
|
||||
return (hash[0] & 1) == 0 ? "allow" : "deny";
|
||||
}
|
||||
|
||||
private static string ComputeCorrelationId(string value)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(value), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Canonicalization;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic canonicalization, digesting, and merge semantics for RiskProfile documents.
|
||||
/// </summary>
|
||||
public static class RiskProfileCanonicalizer
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions SerializeOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
};
|
||||
|
||||
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> utf8Json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(utf8Json, DocOptions);
|
||||
var canonical = CanonicalizeElement(doc.RootElement);
|
||||
return Encoding.UTF8.GetBytes(canonical);
|
||||
}
|
||||
|
||||
public static string CanonicalizeToString(string json)
|
||||
{
|
||||
var utf8 = Encoding.UTF8.GetBytes(json);
|
||||
return Encoding.UTF8.GetString(CanonicalizeToUtf8(utf8));
|
||||
}
|
||||
|
||||
public static string ComputeDigest(string json)
|
||||
{
|
||||
var canonical = CanonicalizeToUtf8(Encoding.UTF8.GetBytes(json));
|
||||
var hash = SHA256.HashData(canonical);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string Merge(string baseProfileJson, string overlayProfileJson)
|
||||
{
|
||||
using var baseDoc = JsonDocument.Parse(baseProfileJson, DocOptions);
|
||||
using var overlayDoc = JsonDocument.Parse(overlayProfileJson, DocOptions);
|
||||
|
||||
var merged = MergeObjects(baseDoc.RootElement, overlayDoc.RootElement);
|
||||
var raw = merged.ToJsonString(SerializeOptions);
|
||||
return CanonicalizeToString(raw);
|
||||
}
|
||||
|
||||
private static string CanonicalizeElement(JsonElement element)
|
||||
{
|
||||
var node = JsonNode.Parse(element.GetRawText())!;
|
||||
CanonicalizeNode(node);
|
||||
return node.ToJsonString(SerializeOptions);
|
||||
}
|
||||
|
||||
private static void CanonicalizeNode(JsonNode node, IReadOnlyList<string>? path = null)
|
||||
{
|
||||
path ??= Array.Empty<string>();
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
foreach (var kvp in obj.ToList())
|
||||
{
|
||||
if (kvp.Value is { } child)
|
||||
{
|
||||
CanonicalizeNode(child, Append(path, kvp.Key));
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = obj.OrderBy(k => k.Key, StringComparer.Ordinal).ToList();
|
||||
obj.Clear();
|
||||
foreach (var kvp in ordered)
|
||||
{
|
||||
obj[kvp.Key] = kvp.Value;
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
var items = array.ToList();
|
||||
foreach (var child in items)
|
||||
{
|
||||
CanonicalizeNode(child!, path);
|
||||
}
|
||||
|
||||
if (IsSignals(path))
|
||||
{
|
||||
items = items.OrderBy(i => i?["name"]?.GetValue<string>(), StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsWeights(path))
|
||||
{
|
||||
// weights are objects, not arrays; no-op
|
||||
}
|
||||
else if (IsSeverityOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
else if (IsDecisionOverrides(path))
|
||||
{
|
||||
items = items.OrderBy(GetWhenThenKey, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
array.Clear();
|
||||
foreach (var item in items)
|
||||
{
|
||||
array.Add(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject MergeObjects(JsonElement baseObj, JsonElement overlayObj)
|
||||
{
|
||||
var result = new JsonObject();
|
||||
|
||||
void Copy(JsonElement source)
|
||||
{
|
||||
foreach (var prop in source.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
Copy(baseObj);
|
||||
Copy(overlayObj);
|
||||
|
||||
// Signals
|
||||
var signals = MergeArrayByKey(baseObj, overlayObj, "signals", "name");
|
||||
if (signals is not null)
|
||||
{
|
||||
result["signals"] = signals;
|
||||
}
|
||||
|
||||
// Weights
|
||||
var weights = MergeObjectProperties(baseObj, overlayObj, "weights");
|
||||
if (weights is not null)
|
||||
{
|
||||
result["weights"] = weights;
|
||||
}
|
||||
|
||||
// Overrides.severity
|
||||
var overrides = MergeOverrides(baseObj, overlayObj);
|
||||
if (overrides is not null)
|
||||
{
|
||||
result["overrides"] = overrides;
|
||||
}
|
||||
|
||||
// Metadata
|
||||
var metadata = MergeObjectProperties(baseObj, overlayObj, "metadata");
|
||||
if (metadata is not null)
|
||||
{
|
||||
result["metadata"] = metadata;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeOverrides(JsonElement baseObj, JsonElement overlayObj)
|
||||
{
|
||||
JsonElement? BaseOverrides() => baseObj.TryGetProperty("overrides", out var o) ? o : (JsonElement?)null;
|
||||
JsonElement? OverlayOverrides() => overlayObj.TryGetProperty("overrides", out var o) ? o : (JsonElement?)null;
|
||||
|
||||
var baseOverrides = BaseOverrides();
|
||||
var overlayOverrides = OverlayOverrides();
|
||||
if (baseOverrides is null && overlayOverrides is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
var severity = MergeArrayByPredicate(baseOverrides, overlayOverrides, "severity");
|
||||
if (severity is not null)
|
||||
{
|
||||
result["severity"] = severity;
|
||||
}
|
||||
|
||||
var decisions = MergeArrayByPredicate(baseOverrides, overlayOverrides, "decisions");
|
||||
if (decisions is not null)
|
||||
{
|
||||
result["decisions"] = decisions;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeArrayByPredicate(JsonElement? baseObj, JsonElement? overlayObj, string propertyName)
|
||||
{
|
||||
var baseArray = baseObj is { } b && b.TryGetProperty(propertyName, out var ba) && ba.ValueKind == JsonValueKind.Array ? ba : (JsonElement?)null;
|
||||
var overlayArray = overlayObj is { } o && o.TryGetProperty(propertyName, out var oa) && oa.ValueKind == JsonValueKind.Array ? oa : (JsonElement?)null;
|
||||
|
||||
if (baseArray is null && overlayArray is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var item in src.Value.EnumerateArray())
|
||||
{
|
||||
var key = GetWhenThenKey(item);
|
||||
dict[key] = JsonNode.Parse(item.GetRawText())!;
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseArray);
|
||||
Add(overlayArray);
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var kvp in dict.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
arr.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeArrayByKey(JsonElement baseObj, JsonElement overlayObj, string propertyName, string keyName)
|
||||
{
|
||||
JsonElement? Base() => baseObj.TryGetProperty(propertyName, out var s) && s.ValueKind == JsonValueKind.Array ? s : (JsonElement?)null;
|
||||
JsonElement? Overlay() => overlayObj.TryGetProperty(propertyName, out var s) && s.ValueKind == JsonValueKind.Array ? s : (JsonElement?)null;
|
||||
|
||||
var baseArray = Base();
|
||||
var overlayArray = Overlay();
|
||||
if (baseArray is null && overlayArray is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var item in src.Value.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty(keyName, out var keyProp) || keyProp.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = keyProp.GetString() ?? string.Empty;
|
||||
dict[key] = JsonNode.Parse(item.GetRawText())!;
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseArray);
|
||||
Add(overlayArray);
|
||||
|
||||
var arr = new JsonArray();
|
||||
foreach (var kvp in dict.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
arr.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
private static JsonNode? MergeObjectProperties(JsonElement baseObj, JsonElement overlayObj, string propertyName)
|
||||
{
|
||||
var baseProp = baseObj.TryGetProperty(propertyName, out var bp) && bp.ValueKind == JsonValueKind.Object ? bp : (JsonElement?)null;
|
||||
var overlayProp = overlayObj.TryGetProperty(propertyName, out var op) && op.ValueKind == JsonValueKind.Object ? op : (JsonElement?)null;
|
||||
|
||||
if (baseProp is null && overlayProp is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
void Add(JsonElement? src)
|
||||
{
|
||||
if (src is null) return;
|
||||
foreach (var prop in src.Value.EnumerateObject())
|
||||
{
|
||||
result[prop.Name] = JsonNode.Parse(prop.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
Add(baseProp);
|
||||
Add(overlayProp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetWhenThenKey(JsonElement element)
|
||||
{
|
||||
var when = element.TryGetProperty("when", out var whenProp) ? whenProp.GetRawText() : string.Empty;
|
||||
var then = element.TryGetProperty("set", out var setProp) ? setProp.GetRawText() : element.TryGetProperty("action", out var actionProp) ? actionProp.GetRawText() : string.Empty;
|
||||
return when + "|" + then;
|
||||
}
|
||||
|
||||
private static bool IsSignals(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "signals";
|
||||
|
||||
private static bool IsWeights(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "weights";
|
||||
|
||||
private static bool IsSeverityOverrides(IReadOnlyList<string> path)
|
||||
=> path.Count >= 2 && path[^2] == "overrides" && path[^1] == "severity";
|
||||
|
||||
private static bool IsDecisionOverrides(IReadOnlyList<string> path)
|
||||
=> path.Count >= 2 && path[^2] == "overrides" && path[^1] == "decisions";
|
||||
|
||||
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
|
||||
{
|
||||
if (path.Count == 0)
|
||||
{
|
||||
return new[] { segment };
|
||||
}
|
||||
|
||||
var next = new string[path.Count + 1];
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
next[i] = path[i];
|
||||
}
|
||||
next[^1] = segment;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
45
src/Policy/StellaOps.Policy.only.sln
Normal file
45
src/Policy/StellaOps.Policy.only.sln
Normal file
@@ -0,0 +1,45 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31912.275
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.Tests", "__Tests\StellaOps.Policy.Tests\StellaOps.Policy.Tests.csproj", "{D064D5C1-3311-470C-92A1-41E913125C14}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9C200CFD-2A8F-4CF5-BD33-AB8B06DA7C82}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D064D5C1-3311-470C-92A1-41E913125C14}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0F0A9530-1843-4ED0-B2AE-7F38C9EE5001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{39D91A9C-E7D0-4F35-85B2-6F2F92C2EDAC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F45E1F09-7DF1-42BD-9ACD-7E6F0B247E6E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -24,6 +24,10 @@
|
||||
"epss": 0.42,
|
||||
"kev": false
|
||||
},
|
||||
"weighting": {
|
||||
"reachability": 1.0,
|
||||
"exploitability": 0.9
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"field": "request.tenant",
|
||||
|
||||
@@ -111,6 +111,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"weighting": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reachability": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Multiplier to apply when reachability is present (default 1)."
|
||||
},
|
||||
"exploitability": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Multiplier to apply when exploitability evidence exists (default 1)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
| POLICY-SPL-23-003 | DONE (2025-11-26) | Policy Guild | POLICY-SPL-23-002 | Layering/override engine + tests. | `SplLayeringEngine` merges base/overlay with deterministic output and metadata merge; covered by `SplLayeringEngineTests`. |
|
||||
| POLICY-SPL-23-004 | DONE (2025-11-26) | Policy Guild, Audit Guild | POLICY-SPL-23-003 | Explanation tree model + persistence hooks. | `PolicyExplanation`/`PolicyExplanationNode` produced from evaluation with structured nodes; persistence ready for follow-on wiring. |
|
||||
| POLICY-SPL-23-005 | DONE (2025-11-26) | Policy Guild, DevEx Guild | POLICY-SPL-23-004 | Migration tool to baseline SPL packs. | `SplMigrationTool` converts PolicyDocument to canonical SPL JSON; covered by `SplMigrationToolTests`. |
|
||||
| POLICY-SPL-24-001 | DONE (2025-11-26) | Policy Guild, Signals Guild | POLICY-SPL-23-005 | Extend SPL with reachability/exploitability predicates. | SPL schema/sample extended with reachability + exploitability, schema guard tests added. |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class EvidenceSummaryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Summarize_BuildsDeterministicSummary()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2025, 11, 26, 0, 0, 0, TimeSpan.Zero));
|
||||
var service = new EvidenceSummaryService(timeProvider);
|
||||
|
||||
var request = new EvidenceSummaryRequest(
|
||||
EvidenceHash: "stub-evidence-hash",
|
||||
FilePath: "/etc/passwd",
|
||||
Digest: "sha256:123",
|
||||
IngestedAt: null,
|
||||
ConnectorId: "connector-1");
|
||||
|
||||
var response = service.Summarize(request);
|
||||
|
||||
Assert.Equal("stub-evidence-hash", response.EvidenceHash);
|
||||
Assert.Equal("info", response.Summary.Severity); // first byte bucketed to info
|
||||
Assert.Equal("/etc/passwd", response.Summary.Locator.FilePath);
|
||||
Assert.Equal("sha256:123", response.Summary.Locator.Digest);
|
||||
Assert.Equal("connector-1", response.Summary.Provenance.ConnectorId);
|
||||
Assert.Equal(new DateTimeOffset(2025, 12, 13, 05, 00, 11, TimeSpan.Zero), response.Summary.Provenance.IngestedAt);
|
||||
Assert.Contains("stub-eviden", response.Summary.Headline);
|
||||
Assert.Equal(
|
||||
new[] { "severity:info", "path:/etc/passwd", "connector:connector-1" },
|
||||
response.Summary.Signals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Summarize_RequiresEvidenceHash()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(DateTimeOffset.UnixEpoch);
|
||||
var service = new EvidenceSummaryService(timeProvider);
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
service.Summarize(new EvidenceSummaryRequest(string.Empty, null, null, null, null)));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyBundleServiceTests
|
||||
{
|
||||
private const string BaselineDsl = """
|
||||
policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
rule r1 { when true then status := "ok" because "baseline" }
|
||||
}
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_SucceedsAndStoresBundle()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var request = new PolicyBundleRequest(new PolicyDslPayload("stella-dsl@1", BaselineDsl), signingKeyId: "test-key");
|
||||
|
||||
var response = await services.BundleService.CompileAndStoreAsync("pack-1", 1, request, CancellationToken.None);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.NotNull(response.Digest);
|
||||
Assert.StartsWith("sig:sha256:", response.Signature);
|
||||
Assert.True(response.SizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompileAndStoreAsync_FailsWithBadSyntax()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var request = new PolicyBundleRequest(new PolicyDslPayload("unknown", "policy bad"), signingKeyId: null);
|
||||
|
||||
var response = await services.BundleService.CompileAndStoreAsync("pack-1", 1, request, CancellationToken.None);
|
||||
|
||||
Assert.False(response.Success);
|
||||
Assert.Null(response.Digest);
|
||||
Assert.NotEmpty(response.Diagnostics);
|
||||
}
|
||||
|
||||
private static ServiceHarness CreateServices()
|
||||
{
|
||||
var compiler = new PolicyCompiler();
|
||||
var complexity = new PolicyComplexityAnalyzer();
|
||||
var options = Options.Create(new PolicyEngineOptions());
|
||||
var compilationService = new PolicyCompilationService(compiler, complexity, new StaticOptionsMonitor(options.Value), TimeProvider.System);
|
||||
var repo = new InMemoryPolicyPackRepository();
|
||||
return new ServiceHarness(
|
||||
new PolicyBundleService(compilationService, repo, TimeProvider.System));
|
||||
}
|
||||
|
||||
private sealed record ServiceHarness(PolicyBundleService BundleService);
|
||||
|
||||
private sealed class StaticOptionsMonitor : IOptionsMonitor<PolicyEngineOptions>
|
||||
{
|
||||
private readonly PolicyEngineOptions _value;
|
||||
|
||||
public StaticOptionsMonitor(PolicyEngineOptions value) => _value = value;
|
||||
|
||||
public PolicyEngineOptions CurrentValue => _value;
|
||||
|
||||
public PolicyEngineOptions Get(string? name) => _value;
|
||||
|
||||
public IDisposable OnChange(Action<PolicyEngineOptions, string> listener) => NullDisposable.Instance;
|
||||
|
||||
private sealed class NullDisposable : IDisposable
|
||||
{
|
||||
public static readonly NullDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyRuntimeEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsDeterministicDecisionAndCaches()
|
||||
{
|
||||
var repo = new InMemoryPolicyPackRepository();
|
||||
await repo.StoreBundleAsync(
|
||||
"pack-1",
|
||||
1,
|
||||
new PolicyBundleRecord(
|
||||
Digest: "sha256:abc",
|
||||
Signature: "sig:sha256:abc",
|
||||
Size: 4,
|
||||
CreatedAt: DateTimeOffset.UnixEpoch,
|
||||
Payload: new byte[] { 1, 2, 3, 4 }.ToImmutableArray()),
|
||||
CancellationToken.None);
|
||||
|
||||
var evaluator = new PolicyRuntimeEvaluator(repo);
|
||||
var request = new PolicyEvaluationRequest("pack-1", 1, "subject-a");
|
||||
|
||||
var first = await evaluator.EvaluateAsync(request, CancellationToken.None);
|
||||
var second = await evaluator.EvaluateAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.Equal(first.Decision, second.Decision);
|
||||
Assert.False(first.Cached);
|
||||
Assert.True(second.Cached);
|
||||
Assert.Equal("pack-1", first.PackId);
|
||||
Assert.Equal(1, first.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ThrowsWhenBundleMissing()
|
||||
{
|
||||
var evaluator = new PolicyRuntimeEvaluator(new InMemoryPolicyPackRepository());
|
||||
var request = new PolicyEvaluationRequest("pack-x", 1, "subject-a");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => evaluator.EvaluateAsync(request, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.RiskProfile.Canonicalization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.RiskProfile.Tests;
|
||||
|
||||
public class RiskProfileCanonicalizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Canonicalize_SortsSignalsAndOverrides()
|
||||
{
|
||||
const string input = """
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"id": "profile",
|
||||
"signals": [
|
||||
{"name": "kev", "source": "cisa", "type": "boolean"},
|
||||
{"name": "reachability", "type": "boolean", "source": "signals"}
|
||||
],
|
||||
"weights": {"reachability": 0.6, "kev": 0.4},
|
||||
"overrides": {
|
||||
"severity": [
|
||||
{"when": {"kev": true}, "set": "critical"},
|
||||
{"when": {"reachability": false}, "set": "low"}
|
||||
],
|
||||
"decisions": [
|
||||
{"when": {"reachability": false}, "action": "review"},
|
||||
{"when": {"kev": true}, "action": "deny"}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var canonical = RiskProfileCanonicalizer.CanonicalizeToString(input);
|
||||
|
||||
const string expected = "{\"id\":\"profile\",\"overrides\":{\"decisions\":[{\"action\":\"deny\",\"when\":{\"kev\":true}},{\"action\":\"review\",\"when\":{\"reachability\":false}}],\"severity\":[{\"set\":\"critical\",\"when\":{\"kev\":true}},{\"set\":\"low\",\"when\":{\"reachability\":false}}]},\"signals\":[{\"name\":\"kev\",\"source\":\"cisa\",\"type\":\"boolean\"},{\"name\":\"reachability\",\"source\":\"signals\",\"type\":\"boolean\"}],\"version\":\"1.0.0\",\"weights\":{\"kev\":0.4,\"reachability\":0.6}}";
|
||||
|
||||
Assert.Equal(expected, canonical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDigest_IgnoresOrderingNoise()
|
||||
{
|
||||
const string a = """
|
||||
{"id":"p","version":"1.0.0","signals":[{"name":"b","source":"x","type":"boolean"},{"name":"a","source":"y","type":"boolean"}],"weights":{"b":0.5,"a":0.5},"overrides":{"severity":[{"when":{"a":true},"set":"high"}],"decisions":[{"when":{"b":false},"action":"review"}]}}
|
||||
""";
|
||||
const string b = """
|
||||
{"version":"1.0.0","id":"p","weights":{"a":0.5,"b":0.5},"signals":[{"source":"y","name":"a","type":"boolean"},{"type":"boolean","name":"b","source":"x"}],"overrides":{"decisions":[{"action":"review","when":{"b":false}}],"severity":[{"set":"high","when":{"a":true}}]}}
|
||||
""";
|
||||
|
||||
var hashA = RiskProfileCanonicalizer.ComputeDigest(a);
|
||||
var hashB = RiskProfileCanonicalizer.ComputeDigest(b);
|
||||
|
||||
Assert.Equal(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_ReplacesSignalsAndWeights()
|
||||
{
|
||||
const string baseProfile = """
|
||||
{"id":"p","version":"1.0.0","signals":[{"name":"reachability","source":"signals","type":"boolean"}],"weights":{"reachability":0.7},"overrides":{"decisions":[{"when":{"reachability":false},"action":"review"}]}}
|
||||
""";
|
||||
|
||||
const string overlay = """
|
||||
{"signals":[{"name":"kev","source":"cisa","type":"boolean"}],"weights":{"kev":0.5},"overrides":{"decisions":[{"when":{"kev":true},"action":"deny"}]}}
|
||||
""";
|
||||
|
||||
var merged = RiskProfileCanonicalizer.Merge(baseProfile, overlay);
|
||||
using var doc = JsonDocument.Parse(merged);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal(2, root.GetProperty("signals").GetArrayLength());
|
||||
Assert.Equal(2, root.GetProperty("weights").EnumerateObject().Count());
|
||||
|
||||
var decisions = root.GetProperty("overrides").GetProperty("decisions").EnumerateArray().ToArray();
|
||||
Assert.Contains(decisions, d => d.GetProperty("action").GetString() == "deny");
|
||||
Assert.Contains(decisions, d => d.GetProperty("action").GetString() == "review");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user