up
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# StellaOps.Policy — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver the policy engine outlined in `docs/modules/scanner/ARCHITECTURE.md` and related prose:
|
||||
- Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates).
|
||||
Deliver the policy engine outlined in `docs/modules/policy/architecture.md`:
|
||||
- Define SPL v1 schema (policy documents, statements, conditions) and scoring schema; keep fixtures and embedded resources current.
|
||||
- Provide policy snapshot storage with revision digests and diagnostics.
|
||||
- Offer preview APIs to compare policy impacts on existing reports.
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@ namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyEvaluation
|
||||
{
|
||||
public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding)
|
||||
{
|
||||
public static PolicyVerdict EvaluateFinding(
|
||||
PolicyDocument document,
|
||||
PolicyScoringConfig scoringConfig,
|
||||
PolicyFinding finding,
|
||||
out PolicyExplanation? explanation)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
@@ -40,35 +44,49 @@ public static class PolicyEvaluation
|
||||
resolvedReachabilityKey);
|
||||
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
if (!RuleMatches(rule, finding))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
if (!RuleMatches(rule, finding))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence, out explanation);
|
||||
}
|
||||
|
||||
explanation = new PolicyExplanation(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Allowed,
|
||||
null,
|
||||
"No rule matched; baseline applied",
|
||||
ImmutableArray.Create(PolicyExplanationNode.Leaf("rule", "No matching rule")));
|
||||
|
||||
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
|
||||
return ApplyUnknownConfidence(baseline, unknownConfidence);
|
||||
}
|
||||
|
||||
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence);
|
||||
}
|
||||
|
||||
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
|
||||
return ApplyUnknownConfidence(baseline, unknownConfidence);
|
||||
}
|
||||
|
||||
private static PolicyVerdict BuildVerdict(
|
||||
PolicyRule rule,
|
||||
PolicyFinding finding,
|
||||
PolicyScoringConfig config,
|
||||
ScoringComponents components,
|
||||
UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
private static PolicyVerdict BuildVerdict(
|
||||
PolicyRule rule,
|
||||
PolicyFinding finding,
|
||||
PolicyScoringConfig config,
|
||||
ScoringComponents components,
|
||||
UnknownConfidenceResult? unknownConfidence,
|
||||
out PolicyExplanation explanation)
|
||||
{
|
||||
var action = rule.Action;
|
||||
var status = MapAction(action);
|
||||
var notes = BuildNotes(action);
|
||||
var notes = BuildNotes(action);
|
||||
var explanationNodes = ImmutableArray.CreateBuilder<PolicyExplanationNode>();
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("rule", $"Matched rule '{rule.Name}'", rule.Identifier));
|
||||
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
inputs["severityWeight"] = components.SeverityWeight;
|
||||
inputs["trustWeight"] = components.TrustWeight;
|
||||
inputs["reachabilityWeight"] = components.ReachabilityWeight;
|
||||
inputs["baseScore"] = components.BaseScore;
|
||||
inputs["baseScore"] = components.BaseScore;
|
||||
explanationNodes.Add(PolicyExplanationNode.Branch("score", "Base score", components.BaseScore.ToString(CultureInfo.InvariantCulture),
|
||||
PolicyExplanationNode.Leaf("severityWeight", "Severity weight", components.SeverityWeight.ToString(CultureInfo.InvariantCulture)),
|
||||
PolicyExplanationNode.Leaf("trustWeight", "Trust weight", components.TrustWeight.ToString(CultureInfo.InvariantCulture)),
|
||||
PolicyExplanationNode.Leaf("reachabilityWeight", "Reachability weight", components.ReachabilityWeight.ToString(CultureInfo.InvariantCulture))));
|
||||
if (!string.IsNullOrWhiteSpace(components.TrustKey))
|
||||
{
|
||||
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
|
||||
@@ -79,13 +97,14 @@ public static class PolicyEvaluation
|
||||
}
|
||||
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
|
||||
{
|
||||
notes = AppendNote(notes, description);
|
||||
}
|
||||
if (unknownConfidence is { } unknownDetails)
|
||||
{
|
||||
inputs["unknownConfidence"] = unknownDetails.Confidence;
|
||||
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
|
||||
}
|
||||
notes = AppendNote(notes, description);
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("unknown", description));
|
||||
}
|
||||
if (unknownConfidence is { } unknownDetails)
|
||||
{
|
||||
inputs["unknownConfidence"] = unknownDetails.Confidence;
|
||||
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
|
||||
}
|
||||
|
||||
double score = components.BaseScore;
|
||||
string? quietedBy = null;
|
||||
@@ -94,8 +113,8 @@ public static class PolicyEvaluation
|
||||
var quietRequested = action.Quiet;
|
||||
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
|
||||
|
||||
if (quietRequested && !quietAllowed)
|
||||
{
|
||||
if (quietRequested && !quietAllowed)
|
||||
{
|
||||
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in inputs)
|
||||
{
|
||||
@@ -112,10 +131,17 @@ public static class PolicyEvaluation
|
||||
var warnScore = Math.Max(0, components.BaseScore - warnPenalty);
|
||||
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Warned,
|
||||
rule.Name,
|
||||
explanation = new PolicyExplanation(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Warned,
|
||||
rule.Name,
|
||||
"Quiet flag ignored; requireVex not provided",
|
||||
explanationNodes.ToImmutable());
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
PolicyVerdictStatus.Warned,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
warnNotes,
|
||||
warnScore,
|
||||
@@ -130,33 +156,56 @@ public static class PolicyEvaluation
|
||||
Reachability: components.ReachabilityKey);
|
||||
}
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case PolicyVerdictStatus.Ignored:
|
||||
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Warned:
|
||||
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Deferred:
|
||||
var deferPenalty = config.WarnPenalty / 2;
|
||||
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
|
||||
break;
|
||||
}
|
||||
if (status != PolicyVerdictStatus.Allowed)
|
||||
{
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("action", $"Action {action.Type}", status.ToString()));
|
||||
}
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case PolicyVerdictStatus.Ignored:
|
||||
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Ignore penalty", config.IgnorePenalty.ToString(CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
case PolicyVerdictStatus.Warned:
|
||||
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Warn penalty", config.WarnPenalty.ToString(CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
case PolicyVerdictStatus.Deferred:
|
||||
var deferPenalty = config.WarnPenalty / 2;
|
||||
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Defer penalty", deferPenalty.ToString(CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
}
|
||||
|
||||
if (quietAllowed)
|
||||
{
|
||||
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
|
||||
quietedBy = rule.Name;
|
||||
quiet = true;
|
||||
}
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
status,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
notes,
|
||||
if (quietAllowed)
|
||||
{
|
||||
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
|
||||
quietedBy = rule.Name;
|
||||
quiet = true;
|
||||
explanationNodes.Add(PolicyExplanationNode.Leaf("quiet", "Quiet applied", config.QuietPenalty.ToString(CultureInfo.InvariantCulture)));
|
||||
}
|
||||
|
||||
explanation = new PolicyExplanation(
|
||||
finding.FindingId,
|
||||
status,
|
||||
rule.Name,
|
||||
notes,
|
||||
explanationNodes.ToImmutable());
|
||||
|
||||
explanation = new PolicyExplanation(
|
||||
finding.FindingId,
|
||||
status,
|
||||
rule.Name,
|
||||
notes,
|
||||
explanationNodes.ToImmutable());
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
status,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
notes,
|
||||
score,
|
||||
config.Version,
|
||||
inputs.ToImmutable(),
|
||||
@@ -180,12 +229,12 @@ public static class PolicyEvaluation
|
||||
return Math.Max(0, score - penalty);
|
||||
}
|
||||
|
||||
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
if (unknownConfidence is null)
|
||||
{
|
||||
return verdict;
|
||||
}
|
||||
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
if (unknownConfidence is null)
|
||||
{
|
||||
return verdict;
|
||||
}
|
||||
|
||||
var inputsBuilder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in verdict.GetInputs())
|
||||
@@ -196,12 +245,12 @@ public static class PolicyEvaluation
|
||||
inputsBuilder["unknownConfidence"] = unknownConfidence.Value.Confidence;
|
||||
inputsBuilder["unknownAgeDays"] = unknownConfidence.Value.AgeDays;
|
||||
|
||||
return verdict with
|
||||
{
|
||||
Inputs = inputsBuilder.ToImmutable(),
|
||||
UnknownConfidence = unknownConfidence.Value.Confidence,
|
||||
ConfidenceBand = unknownConfidence.Value.Band.Name,
|
||||
UnknownAgeDays = unknownConfidence.Value.AgeDays,
|
||||
return verdict with
|
||||
{
|
||||
Inputs = inputsBuilder.ToImmutable(),
|
||||
UnknownConfidence = unknownConfidence.Value.Confidence,
|
||||
ConfidenceBand = unknownConfidence.Value.Band.Name,
|
||||
UnknownAgeDays = unknownConfidence.Value.AgeDays,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
48
src/Policy/__Libraries/StellaOps.Policy/PolicyExplanation.cs
Normal file
48
src/Policy/__Libraries/StellaOps.Policy/PolicyExplanation.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Structured explanation describing how a policy decision was reached.
|
||||
/// </summary>
|
||||
/// <param name="FindingId">Identifier of the evaluated finding.</param>
|
||||
/// <param name="Decision">Final verdict status (e.g., Allow, Block, Warned).</param>
|
||||
/// <param name="RuleName">Name of the rule that matched, if any.</param>
|
||||
/// <param name="Reason">Human-readable summary.</param>
|
||||
/// <param name="Nodes">Tree of evaluated nodes (rule, match, action, penalties, quieting, unknown confidence).</param>
|
||||
public sealed record PolicyExplanation(
|
||||
string FindingId,
|
||||
PolicyVerdictStatus Decision,
|
||||
string? RuleName,
|
||||
string Reason,
|
||||
ImmutableArray<PolicyExplanationNode> Nodes)
|
||||
{
|
||||
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
||||
new(findingId, PolicyVerdictStatus.Allowed, ruleName, reason, nodes.ToImmutableArray());
|
||||
|
||||
public static PolicyExplanation Block(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
||||
new(findingId, PolicyVerdictStatus.Blocked, ruleName, reason, nodes.ToImmutableArray());
|
||||
|
||||
public static PolicyExplanation Warn(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
|
||||
new(findingId, PolicyVerdictStatus.Warned, ruleName, reason, nodes.ToImmutableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single explanation node with optional children to capture evaluation breadcrumbs.
|
||||
/// </summary>
|
||||
/// <param name="Kind">Short classifier (e.g., "rule", "match", "penalty", "quiet", "unknown").</param>
|
||||
/// <param name="Label">Human-readable label.</param>
|
||||
/// <param name="Detail">Optional detail (numeric or string rendered as text).</param>
|
||||
/// <param name="Children">Nested explanation nodes.</param>
|
||||
public sealed record PolicyExplanationNode(
|
||||
string Kind,
|
||||
string Label,
|
||||
string? Detail,
|
||||
ImmutableArray<PolicyExplanationNode> Children)
|
||||
{
|
||||
public static PolicyExplanationNode Leaf(string kind, string label, string? detail = null) =>
|
||||
new(kind, label, detail, ImmutableArray<PolicyExplanationNode>.Empty);
|
||||
|
||||
public static PolicyExplanationNode Branch(string kind, string label, string? detail = null, params PolicyExplanationNode[] children) =>
|
||||
new(kind, label, detail, children.ToImmutableArray());
|
||||
}
|
||||
@@ -93,7 +93,7 @@ public sealed class PolicyPreviewService
|
||||
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
|
||||
foreach (var finding in findings)
|
||||
{
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding);
|
||||
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding, out _);
|
||||
results.Add(verdict);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ public sealed class PolicyValidationCli
|
||||
_error = error ?? Console.Error;
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
@@ -71,8 +71,18 @@ public sealed class PolicyValidationCli
|
||||
|
||||
var format = PolicySchema.DetectFormat(path);
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var bindingResult = PolicyBinder.Bind(content, format);
|
||||
var diagnostics = PolicyDiagnostics.Create(bindingResult);
|
||||
var bindingResult = PolicyBinder.Bind(content, format);
|
||||
var diagnostics = PolicyDiagnostics.Create(bindingResult);
|
||||
|
||||
if (bindingResult.Success && bindingResult.Document is { } doc)
|
||||
{
|
||||
var splJson = SplMigrationTool.ToSplPolicyJson(doc);
|
||||
var splHash = SplCanonicalizer.ComputeDigest(splJson);
|
||||
diagnostics = diagnostics with
|
||||
{
|
||||
Recommendations = diagnostics.Recommendations.Add($"canonical.spl.digest:{splHash}"),
|
||||
};
|
||||
}
|
||||
|
||||
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"apiVersion": "spl.stellaops/v1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "demo-access",
|
||||
"description": "Sample SPL policy allowing read access to demo resources",
|
||||
"labels": {
|
||||
"env": "demo",
|
||||
"owner": "policy-guild"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"defaultEffect": "deny",
|
||||
"statements": [
|
||||
{
|
||||
"id": "allow-read-demo",
|
||||
"effect": "allow",
|
||||
"description": "Allow read on demo resources",
|
||||
"match": {
|
||||
"resource": "demo/*",
|
||||
"actions": ["read"],
|
||||
"reachability": "direct",
|
||||
"exploitability": {
|
||||
"epss": 0.42,
|
||||
"kev": false
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"field": "request.tenant",
|
||||
"operator": "eq",
|
||||
"value": "demo"
|
||||
}
|
||||
]
|
||||
},
|
||||
"audit": {
|
||||
"message": "demo read granted",
|
||||
"severity": "info"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.stellaops.io/policy/spl-schema@1.json",
|
||||
"title": "Stella Policy Language (SPL) v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["apiVersion", "kind", "metadata", "spec"],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"const": "spl.stellaops/v1"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"const": "Policy"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$",
|
||||
"description": "DNS-style name, 1-64 chars, lowercase, hyphen separated"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"annotations": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["statements"],
|
||||
"properties": {
|
||||
"defaultEffect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"],
|
||||
"default": "deny"
|
||||
},
|
||||
"statements": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "effect", "match"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]{1,64}$"
|
||||
},
|
||||
"effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"match": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["resource", "actions"],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"reachability": {
|
||||
"type": "string",
|
||||
"enum": ["none", "indirect", "direct"],
|
||||
"description": "Optional reachability asserted for the matched resource (e.g., entrypoint usage)."
|
||||
},
|
||||
"exploitability": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"epss": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"kev": {
|
||||
"type": "boolean",
|
||||
"description": "Known exploited vulnerability flag."
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["field", "operator", "value"],
|
||||
"properties": {
|
||||
"field": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
},
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"eq",
|
||||
"neq",
|
||||
"gt",
|
||||
"gte",
|
||||
"lt",
|
||||
"lte",
|
||||
"in",
|
||||
"nin",
|
||||
"contains",
|
||||
"startsWith",
|
||||
"endsWith"
|
||||
]
|
||||
},
|
||||
"value": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 512
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"enum": ["info", "warn", "error"],
|
||||
"default": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/Policy/__Libraries/StellaOps.Policy/SplCanonicalizer.cs
Normal file
195
src/Policy/__Libraries/StellaOps.Policy/SplCanonicalizer.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Canonicalizes SPL (Stella Policy Language) documents and produces stable digests.
|
||||
/// Sorting is applied where order is not semantically meaningful (statements, actions, conditions)
|
||||
/// so the same policy yields identical hashes regardless of authoring order or whitespace.
|
||||
/// </summary>
|
||||
public static class SplCanonicalizer
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
Indented = false,
|
||||
SkipValidation = false,
|
||||
};
|
||||
|
||||
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> json)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json, DocumentOptions);
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
|
||||
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
|
||||
{
|
||||
WriteCanonicalValue(writer, document.RootElement, Array.Empty<string>());
|
||||
}
|
||||
|
||||
return buffer.WrittenSpan.ToArray();
|
||||
}
|
||||
|
||||
public static string CanonicalizeToString(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
return Encoding.UTF8.GetString(CanonicalizeToUtf8(bytes));
|
||||
}
|
||||
|
||||
public static string ComputeDigest(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
return ComputeDigest(bytes);
|
||||
}
|
||||
|
||||
public static string ComputeDigest(ReadOnlySpan<byte> json)
|
||||
{
|
||||
var canonical = CanonicalizeToUtf8(json);
|
||||
var hash = SHA256.HashData(canonical);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalValue(Utf8JsonWriter writer, JsonElement element, IReadOnlyList<string> path)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
WriteCanonicalObject(writer, element, path);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
WriteCanonicalArray(writer, element, path);
|
||||
break;
|
||||
default:
|
||||
element.WriteTo(writer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element, IReadOnlyList<string> path)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (var property in element.EnumerateObject().OrderBy(static p => p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(property.Name);
|
||||
WriteCanonicalValue(writer, property.Value, Append(path, property.Name));
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element, IReadOnlyList<string> path)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
|
||||
IEnumerable<JsonElement> sequence = element.EnumerateArray();
|
||||
|
||||
if (IsStatementsPath(path))
|
||||
{
|
||||
sequence = sequence.OrderBy(GetStatementSortKey, StringComparer.Ordinal);
|
||||
}
|
||||
else if (IsActionsPath(path))
|
||||
{
|
||||
sequence = sequence.OrderBy(static v => v.GetString(), StringComparer.Ordinal);
|
||||
}
|
||||
else if (IsConditionsPath(path))
|
||||
{
|
||||
sequence = sequence.OrderBy(GetConditionSortKey, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
foreach (var item in sequence)
|
||||
{
|
||||
WriteCanonicalValue(writer, item, path);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static bool IsStatementsPath(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "statements";
|
||||
|
||||
private static bool IsActionsPath(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "actions";
|
||||
|
||||
private static bool IsConditionsPath(IReadOnlyList<string> path)
|
||||
=> path.Count >= 1 && path[^1] == "conditions";
|
||||
|
||||
private static string GetStatementSortKey(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return id.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string GetConditionSortKey(JsonElement element)
|
||||
{
|
||||
var field = element.TryGetProperty("field", out var f) && f.ValueKind == JsonValueKind.String
|
||||
? f.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var op = element.TryGetProperty("operator", out var o) && o.ValueKind == JsonValueKind.String
|
||||
? o.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var value = element.TryGetProperty("value", out var v)
|
||||
? CanonicalScalar(v)
|
||||
: string.Empty;
|
||||
|
||||
return string.Create(field.Length + op.Length + value.Length + 2, (field, op, value),
|
||||
static (span, state) =>
|
||||
{
|
||||
var (field, op, value) = state;
|
||||
var offset = 0;
|
||||
field.AsSpan().CopyTo(span);
|
||||
offset += field.Length;
|
||||
span[offset++] = '\u0001';
|
||||
op.AsSpan().CopyTo(span[offset..]);
|
||||
offset += op.Length;
|
||||
span[offset++] = '\u0001';
|
||||
value.AsSpan().CopyTo(span[offset..]);
|
||||
});
|
||||
}
|
||||
|
||||
private static string CanonicalScalar(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString() ?? string.Empty,
|
||||
JsonValueKind.Number => element.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => element.GetRawText(),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
212
src/Policy/__Libraries/StellaOps.Policy/SplLayeringEngine.cs
Normal file
212
src/Policy/__Libraries/StellaOps.Policy/SplLayeringEngine.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic layering/override semantics for SPL (Stella Policy Language) documents.
|
||||
/// Overlay statements replace base statements with the same <c>id</c>; metadata labels/annotations merge with overlay precedence.
|
||||
/// The merged output is returned in canonicalized JSON form so hashes remain stable.
|
||||
/// </summary>
|
||||
public static class SplLayeringEngine
|
||||
{
|
||||
private static readonly JsonDocumentOptions DocumentOptions = new()
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Merge two SPL documents and return canonical JSON (sorted properties/statements/actions/conditions).
|
||||
/// </summary>
|
||||
public static string Merge(string basePolicyJson, string overlayPolicyJson)
|
||||
{
|
||||
if (basePolicyJson is null) throw new ArgumentNullException(nameof(basePolicyJson));
|
||||
if (overlayPolicyJson is null) throw new ArgumentNullException(nameof(overlayPolicyJson));
|
||||
|
||||
var merged = MergeToUtf8(Encoding.UTF8.GetBytes(basePolicyJson), Encoding.UTF8.GetBytes(overlayPolicyJson));
|
||||
return Encoding.UTF8.GetString(merged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge two SPL documents and return canonical UTF-8 bytes.
|
||||
/// </summary>
|
||||
public static byte[] MergeToUtf8(ReadOnlySpan<byte> basePolicyUtf8, ReadOnlySpan<byte> overlayPolicyUtf8)
|
||||
{
|
||||
var merged = MergeToJsonNode(basePolicyUtf8, overlayPolicyUtf8);
|
||||
var raw = Encoding.UTF8.GetBytes(merged.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
}));
|
||||
|
||||
return SplCanonicalizer.CanonicalizeToUtf8(raw);
|
||||
}
|
||||
|
||||
private static JsonNode MergeToJsonNode(ReadOnlySpan<byte> basePolicyUtf8, ReadOnlySpan<byte> overlayPolicyUtf8)
|
||||
{
|
||||
using var baseDoc = JsonDocument.Parse(basePolicyUtf8, DocumentOptions);
|
||||
using var overlayDoc = JsonDocument.Parse(overlayPolicyUtf8, DocumentOptions);
|
||||
|
||||
var baseRoot = baseDoc.RootElement;
|
||||
var overlayRoot = overlayDoc.RootElement;
|
||||
|
||||
var result = new JsonObject();
|
||||
|
||||
// apiVersion/kind: overlay wins if present, else base.
|
||||
result["apiVersion"] = overlayRoot.TryGetProperty("apiVersion", out var apiVersion)
|
||||
? apiVersion.GetString()
|
||||
: baseRoot.GetPropertyOrNull("apiVersion")?.GetString();
|
||||
|
||||
result["kind"] = overlayRoot.TryGetProperty("kind", out var kind)
|
||||
? kind.GetString()
|
||||
: baseRoot.GetPropertyOrNull("kind")?.GetString();
|
||||
|
||||
result["metadata"] = MergeMetadata(baseRoot.GetPropertyOrNull("metadata"), overlayRoot.GetPropertyOrNull("metadata"));
|
||||
|
||||
var mergedSpec = MergeSpec(baseRoot.GetPropertyOrNull("spec"), overlayRoot.GetPropertyOrNull("spec"));
|
||||
if (mergedSpec is not null)
|
||||
{
|
||||
result["spec"] = mergedSpec;
|
||||
}
|
||||
|
||||
// Preserve any other top-level fields with overlay precedence.
|
||||
CopyUnknownProperties(baseRoot, result, skipNames: new[] { "apiVersion", "kind", "metadata", "spec" });
|
||||
CopyUnknownProperties(overlayRoot, result, skipNames: new[] { "apiVersion", "kind", "metadata", "spec" });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static JsonObject MergeSpec(JsonElement? baseSpec, JsonElement? overlaySpec)
|
||||
{
|
||||
var spec = new JsonObject();
|
||||
|
||||
if (baseSpec is { ValueKind: JsonValueKind.Object } b)
|
||||
{
|
||||
CopyAllProperties(b, spec);
|
||||
}
|
||||
|
||||
if (overlaySpec is { ValueKind: JsonValueKind.Object } o)
|
||||
{
|
||||
CopyAllProperties(o, spec);
|
||||
}
|
||||
|
||||
// defaultEffect: overlay wins, else base, else schema default "deny".
|
||||
spec["defaultEffect"] = overlaySpec?.GetPropertyOrNull("defaultEffect")?.GetString()
|
||||
?? baseSpec?.GetPropertyOrNull("defaultEffect")?.GetString()
|
||||
?? "deny";
|
||||
|
||||
var mergedStatements = MergeStatements(baseSpec, overlaySpec);
|
||||
spec["statements"] = mergedStatements;
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
private static JsonArray MergeStatements(JsonElement? baseSpec, JsonElement? overlaySpec)
|
||||
{
|
||||
var statements = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
|
||||
|
||||
void AddRange(JsonElement? spec)
|
||||
{
|
||||
if (spec is not { ValueKind: JsonValueKind.Object }) return;
|
||||
if (!spec.Value.TryGetProperty("statements", out var stmts) || stmts.ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
foreach (var statement in stmts.EnumerateArray())
|
||||
{
|
||||
if (statement.ValueKind != JsonValueKind.Object) continue;
|
||||
if (!statement.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) continue;
|
||||
var id = idProp.GetString() ?? string.Empty;
|
||||
statements[id] = JsonNode.Parse(statement.GetRawText())!; // replace if already present
|
||||
}
|
||||
}
|
||||
|
||||
AddRange(baseSpec);
|
||||
AddRange(overlaySpec);
|
||||
|
||||
var merged = new JsonArray();
|
||||
foreach (var kvp in statements.OrderBy(k => k.Key, StringComparer.Ordinal))
|
||||
{
|
||||
merged.Add(kvp.Value);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static JsonObject MergeMetadata(JsonElement? baseMeta, JsonElement? overlayMeta)
|
||||
{
|
||||
var meta = new JsonObject();
|
||||
|
||||
if (baseMeta is { ValueKind: JsonValueKind.Object } b)
|
||||
{
|
||||
CopyAllProperties(b, meta);
|
||||
}
|
||||
|
||||
if (overlayMeta is { ValueKind: JsonValueKind.Object } o)
|
||||
{
|
||||
CopyAllProperties(o, meta);
|
||||
}
|
||||
|
||||
meta["labels"] = MergeStringMap(
|
||||
baseMeta.GetPropertyOrNull("labels"),
|
||||
overlayMeta.GetPropertyOrNull("labels"));
|
||||
|
||||
meta["annotations"] = MergeStringMap(
|
||||
baseMeta.GetPropertyOrNull("annotations"),
|
||||
overlayMeta.GetPropertyOrNull("annotations"));
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
private static JsonObject MergeStringMap(JsonElement? baseMap, JsonElement? overlayMap)
|
||||
{
|
||||
var map = new JsonObject();
|
||||
|
||||
if (baseMap is { ValueKind: JsonValueKind.Object } b)
|
||||
{
|
||||
CopyAllProperties(b, map);
|
||||
}
|
||||
|
||||
if (overlayMap is { ValueKind: JsonValueKind.Object } o)
|
||||
{
|
||||
CopyAllProperties(o, map);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void CopyAllProperties(JsonElement element, JsonObject target)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
target[property.Name] = JsonNode.Parse(property.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyUnknownProperties(JsonElement element, JsonObject target, string[] skipNames)
|
||||
{
|
||||
var skip = new HashSet<string>(skipNames, StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (skip.Contains(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
target[property.Name] = JsonNode.Parse(property.Value.GetRawText());
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? GetPropertyOrNull(this JsonElement? element, string name)
|
||||
{
|
||||
if (element is not { ValueKind: JsonValueKind.Object })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.Value.TryGetProperty(name, out var value) ? value : (JsonElement?)null;
|
||||
}
|
||||
}
|
||||
168
src/Policy/__Libraries/StellaOps.Policy/SplMigrationTool.cs
Normal file
168
src/Policy/__Libraries/StellaOps.Policy/SplMigrationTool.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
/// <summary>
|
||||
/// Converts legacy <see cref="PolicyDocument"/> instances to SPL (Stella Policy Language) JSON packs.
|
||||
/// Output is canonicalised for deterministic hashing and downstream packaging.
|
||||
/// </summary>
|
||||
public static class SplMigrationTool
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = null,
|
||||
};
|
||||
|
||||
public static string ToSplPolicyJson(PolicyDocument document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var node = BuildNode(document);
|
||||
var utf8 = Encoding.UTF8.GetBytes(node.ToJsonString(SerializerOptions));
|
||||
var canonical = SplCanonicalizer.CanonicalizeToUtf8(utf8);
|
||||
return Encoding.UTF8.GetString(canonical);
|
||||
}
|
||||
|
||||
private static JsonNode BuildNode(PolicyDocument document)
|
||||
{
|
||||
var root = new JsonObject
|
||||
{
|
||||
["apiVersion"] = "spl.stellaops/v1",
|
||||
["kind"] = "Policy",
|
||||
["metadata"] = BuildMetadata(document.Metadata),
|
||||
["spec"] = BuildSpec(document)
|
||||
};
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JsonObject BuildMetadata(ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
var labels = new JsonObject();
|
||||
foreach (var pair in metadata.OrderBy(static p => p.Key, StringComparer.Ordinal))
|
||||
{
|
||||
labels[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["name"] = labels.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue ? nameNode : null,
|
||||
["labels"] = labels
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonObject BuildSpec(PolicyDocument document)
|
||||
{
|
||||
var statements = new JsonArray();
|
||||
foreach (var rule in document.Rules.OrderBy(static r => r.Identifier ?? r.Name, StringComparer.Ordinal))
|
||||
{
|
||||
statements.Add(BuildStatement(rule));
|
||||
}
|
||||
|
||||
var spec = new JsonObject
|
||||
{
|
||||
["defaultEffect"] = "deny",
|
||||
["statements"] = statements
|
||||
};
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
private static JsonObject BuildStatement(PolicyRule rule)
|
||||
{
|
||||
var id = rule.Identifier ?? Slug(rule.Name);
|
||||
var effect = MapEffect(rule.Action.Type);
|
||||
|
||||
var statement = new JsonObject
|
||||
{
|
||||
["id"] = id,
|
||||
["effect"] = effect,
|
||||
["match"] = BuildMatch(rule.Match)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rule.Description))
|
||||
{
|
||||
statement["description"] = rule.Description;
|
||||
}
|
||||
|
||||
if (rule.Action.Type is PolicyActionType.Warn or PolicyActionType.Defer or PolicyActionType.Ignore)
|
||||
{
|
||||
statement["audit"] = new JsonObject
|
||||
{
|
||||
["message"] = rule.Justification ?? rule.Name,
|
||||
["severity"] = rule.Action.Type == PolicyActionType.Warn ? "warn" : "info"
|
||||
};
|
||||
}
|
||||
|
||||
return statement;
|
||||
}
|
||||
|
||||
private static JsonObject BuildMatch(PolicyRuleMatchCriteria match)
|
||||
{
|
||||
var actions = new JsonArray();
|
||||
var resources = new JsonArray();
|
||||
|
||||
foreach (var pkg in match.Packages)
|
||||
{
|
||||
resources.Add(pkg);
|
||||
actions.Add("use");
|
||||
}
|
||||
|
||||
foreach (var path in match.Paths)
|
||||
{
|
||||
resources.Add(path);
|
||||
actions.Add("access");
|
||||
}
|
||||
|
||||
// Ensure at least one action + resource to satisfy SPL schema.
|
||||
if (resources.Count == 0)
|
||||
{
|
||||
resources.Add("*");
|
||||
actions.Add("read");
|
||||
}
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["resource"] = resources[0],
|
||||
["actions"] = actions
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapEffect(PolicyActionType type) => type switch
|
||||
{
|
||||
PolicyActionType.Block => "deny",
|
||||
PolicyActionType.Escalate => "deny",
|
||||
PolicyActionType.RequireVex => "deny",
|
||||
_ => "allow",
|
||||
};
|
||||
|
||||
private static string Slug(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return "unnamed";
|
||||
}
|
||||
|
||||
var chars = name.ToLowerInvariant()
|
||||
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray();
|
||||
|
||||
var slug = new string(chars);
|
||||
while (slug.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
slug = slug.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
return slug.Trim('-');
|
||||
}
|
||||
}
|
||||
48
src/Policy/__Libraries/StellaOps.Policy/SplSchemaResource.cs
Normal file
48
src/Policy/__Libraries/StellaOps.Policy/SplSchemaResource.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class SplSchemaResource
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.spl-schema@1.json";
|
||||
private const string SampleResourceName = "StellaOps.Policy.Schemas.spl-sample@1.json";
|
||||
|
||||
public static Stream OpenSchemaStream()
|
||||
{
|
||||
return OpenResourceStream(SchemaResourceName);
|
||||
}
|
||||
|
||||
public static string ReadSchemaJson()
|
||||
{
|
||||
using var stream = OpenSchemaStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
public static Stream OpenSampleStream()
|
||||
{
|
||||
return OpenResourceStream(SampleResourceName);
|
||||
}
|
||||
|
||||
public static string ReadSampleJson()
|
||||
{
|
||||
using var stream = OpenSampleStream();
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static Stream OpenResourceStream(string resourceName)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Unable to locate embedded resource '{resourceName}'.");
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,3 +3,8 @@
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| POLICY-EXC-25-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
|
||||
| POLICY-SPL-23-001 | DONE (2025-11-25) | Policy Guild | — | Define SPL v1 schema + fixtures; embed schema/sample in `StellaOps.Policy` with loader helper. | `spl-schema@1.json` and `spl-sample@1.json` embedded; `SplSchemaResource` exposes schema/sample; sprint 0128 task closed. |
|
||||
| POLICY-SPL-23-002 | DONE (2025-11-26) | Policy Guild | POLICY-SPL-23-001 | Canonicalizer + content hashing for SPL policies. | Order-stable canonicalizer (statements/actions/conditions), SHA-256 digest helper, and unit tests in `SplCanonicalizerTests`. |
|
||||
| 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`. |
|
||||
|
||||
Reference in New Issue
Block a user