Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal interface IPolicyPackRepository
|
||||
{
|
||||
Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken);
|
||||
|
||||
Task<PolicyActivationResult> RecordActivationAsync(string packId, int version, string actorId, DateTimeOffset timestamp, string? comment, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record PolicyActivationResult(PolicyActivationResultStatus Status, PolicyRevisionRecord? Revision);
|
||||
|
||||
internal enum PolicyActivationResultStatus
|
||||
{
|
||||
PackNotFound,
|
||||
RevisionNotFound,
|
||||
NotApproved,
|
||||
DuplicateApproval,
|
||||
PendingSecondApproval,
|
||||
Activated,
|
||||
AlreadyActive
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class InMemoryPolicyPackRepository : IPolicyPackRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PolicyPackRecord> packs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<PolicyPackRecord> CreateAsync(string packId, string? displayName, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(packId);
|
||||
|
||||
var created = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, displayName, DateTimeOffset.UtcNow));
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PolicyPackRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IReadOnlyList<PolicyPackRecord> list = packs.Values
|
||||
.OrderBy(pack => pack.PackId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult(list);
|
||||
}
|
||||
|
||||
public Task<PolicyRevisionRecord> UpsertRevisionAsync(string packId, int version, bool requiresTwoPersonApproval, PolicyRevisionStatus initialStatus, CancellationToken cancellationToken)
|
||||
{
|
||||
var pack = packs.GetOrAdd(packId, id => new PolicyPackRecord(id, null, DateTimeOffset.UtcNow));
|
||||
int revisionVersion = version > 0 ? version : pack.GetNextVersion();
|
||||
var revision = pack.GetOrAddRevision(
|
||||
revisionVersion,
|
||||
v => new PolicyRevisionRecord(v, requiresTwoPersonApproval, initialStatus, DateTimeOffset.UtcNow));
|
||||
|
||||
if (revision.Status != initialStatus)
|
||||
{
|
||||
revision.SetStatus(initialStatus, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
return Task.FromResult(revision);
|
||||
}
|
||||
|
||||
public Task<PolicyRevisionRecord?> GetRevisionAsync(string packId, int version, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!packs.TryGetValue(packId, out var pack))
|
||||
{
|
||||
return Task.FromResult<PolicyRevisionRecord?>(null);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
if (!pack.TryGetRevision(version, out var revision))
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.RevisionNotFound, null));
|
||||
}
|
||||
|
||||
if (revision.Status == PolicyRevisionStatus.Active)
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.AlreadyActive, revision));
|
||||
}
|
||||
|
||||
if (revision.Status != PolicyRevisionStatus.Approved)
|
||||
{
|
||||
return Task.FromResult(new PolicyActivationResult(PolicyActivationResultStatus.NotApproved, revision));
|
||||
}
|
||||
|
||||
var approvalStatus = revision.AddApproval(new PolicyActivationApproval(actorId, timestamp, comment));
|
||||
return Task.FromResult(approvalStatus switch
|
||||
{
|
||||
PolicyActivationApprovalStatus.Duplicate => new PolicyActivationResult(PolicyActivationResultStatus.DuplicateApproval, revision),
|
||||
PolicyActivationApprovalStatus.Pending when revision.RequiresTwoPersonApproval
|
||||
=> new PolicyActivationResult(PolicyActivationResultStatus.PendingSecondApproval, revision),
|
||||
PolicyActivationApprovalStatus.Pending =>
|
||||
ActivateRevision(revision, timestamp),
|
||||
PolicyActivationApprovalStatus.ThresholdReached =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic compilation for <c>stella-dsl@1</c> policy documents and exposes
|
||||
/// basic statistics consumed by API/CLI surfaces.
|
||||
/// </summary>
|
||||
internal sealed class PolicyCompilationService
|
||||
{
|
||||
private readonly PolicyCompiler compiler;
|
||||
|
||||
public PolicyCompilationService(PolicyCompiler compiler)
|
||||
{
|
||||
this.compiler = compiler ?? throw new ArgumentNullException(nameof(compiler));
|
||||
}
|
||||
|
||||
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Dsl is null || string.IsNullOrWhiteSpace(request.Dsl.Source))
|
||||
{
|
||||
throw new ArgumentException("Compilation requires DSL source.", nameof(request));
|
||||
}
|
||||
|
||||
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyCompilationResultDto.FromFailure(
|
||||
ImmutableArray.Create(PolicyIssue.Error(
|
||||
PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion,
|
||||
$"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.",
|
||||
"dsl.syntax")));
|
||||
}
|
||||
|
||||
var result = compiler.Compile(request.Dsl.Source);
|
||||
if (!result.Success || result.Document is null)
|
||||
{
|
||||
return PolicyCompilationResultDto.FromFailure(result.Diagnostics);
|
||||
}
|
||||
|
||||
return PolicyCompilationResultDto.FromSuccess(result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl);
|
||||
|
||||
internal sealed record PolicyDslPayload(string Syntax, string Source);
|
||||
|
||||
internal sealed record PolicyCompilationResultDto(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
PolicyCompilationStatistics? Statistics,
|
||||
ImmutableArray<PolicyIssue> Diagnostics)
|
||||
{
|
||||
public static PolicyCompilationResultDto FromFailure(ImmutableArray<PolicyIssue> diagnostics) =>
|
||||
new(false, null, null, diagnostics);
|
||||
|
||||
public static PolicyCompilationResultDto FromSuccess(PolicyCompilationResult compilationResult)
|
||||
{
|
||||
if (compilationResult.Document is null)
|
||||
{
|
||||
throw new ArgumentException("Compilation result must include a document for success.", nameof(compilationResult));
|
||||
}
|
||||
|
||||
var stats = PolicyCompilationStatistics.Create(compilationResult.Document);
|
||||
return new PolicyCompilationResultDto(
|
||||
true,
|
||||
$"sha256:{compilationResult.Checksum}",
|
||||
stats,
|
||||
compilationResult.Diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompilationStatistics(
|
||||
int RuleCount,
|
||||
ImmutableDictionary<string, int> ActionCounts)
|
||||
{
|
||||
public static PolicyCompilationStatistics Create(PolicyIrDocument document)
|
||||
{
|
||||
var actions = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Increment(string key)
|
||||
{
|
||||
actions[key] = actions.TryGetValue(key, out var existing) ? existing + 1 : 1;
|
||||
}
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
Increment(GetActionKey(action));
|
||||
}
|
||||
|
||||
foreach (var action in rule.ElseActions)
|
||||
{
|
||||
Increment($"else:{GetActionKey(action)}");
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyCompilationStatistics(document.Rules.Length, actions.ToImmutable());
|
||||
}
|
||||
|
||||
private static string GetActionKey(PolicyIrAction action) => action switch
|
||||
{
|
||||
PolicyIrAssignmentAction => "assign",
|
||||
PolicyIrAnnotateAction => "annotate",
|
||||
PolicyIrIgnoreAction => "ignore",
|
||||
PolicyIrEscalateAction => "escalate",
|
||||
PolicyIrRequireVexAction => "requireVex",
|
||||
PolicyIrWarnAction => "warn",
|
||||
PolicyIrDeferAction => "defer",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal sealed class PolicyEvaluationService
|
||||
{
|
||||
private readonly PolicyEvaluator evaluator = new();
|
||||
|
||||
public PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var request = new PolicyEvaluationRequest(document, context);
|
||||
return evaluator.Evaluate(request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
internal static class ScopeAuthorization
|
||||
{
|
||||
private static readonly StringComparer ScopeComparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
public static IResult? RequireScope(HttpContext context, string requiredScope)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
throw new ArgumentException("Scope must be provided.", nameof(requiredScope));
|
||||
}
|
||||
|
||||
var user = context.User;
|
||||
if (user?.Identity?.IsAuthenticated is not true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (!HasScope(user, requiredScope))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasScope(ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
foreach (var claim in principal.FindAll("scope").Concat(principal.FindAll("scp")))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (scopes.Any(value => ScopeComparer.Equals(value, scope)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user