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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user